Note

You can download this example as a Jupyter notebook or try it out directly in Google Colab.

8. Market Zone Coupling in the ASSUME Framework#

Welcome to the Market Zone Coupling tutorial for the ASSUME framework. In this workshop, we will guide you through understanding how market zone coupling is implemented within the ASSUME simulation environment. By the end of this tutorial, you will gain insights into the internal mechanisms of the framework, including how different market zones interact, how constraints are managed, how bids are assigned, and how market prices are extracted.

We will cover the following topics:

  1. Introduction to Market Zone Coupling

  2. Setting Up the ASSUME Framework for Market Zone Coupling

  3. Understanding the Market Clearing Optimization

  4. Creating Exemplary Input Files for Market Zone Coupling

    • 4.1. Defining Buses and Zones

    • 4.2. Configuring Transmission Lines

    • 4.3. Setting Up Power Plant and Demand Units

    • 4.4. Preparing Demand Data

  5. Understanding the Market Clearing with Zone Coupling

    • 5.1. Calculating the Incidence Matrix

    • 5.2. Implementing the Simplified Market Clearing Function

    • 5.3. Running the Market Clearing Simulation

    • 5.4. Extracting and Interpreting the Results

    • 5.5. Comparing Simulations

  6. Execution with ASSUME

  7. Analyzing the Results

Let’s get started!

1. Introduction to Market Zone Coupling#

Market Zone Coupling is a mechanism that enables different geographical zones within an electricity market to interact and trade energy seamlessly. In the ASSUME framework, implementing market zone coupling is straightforward: by properly defining the input data and configuration files, the framework automatically manages the interactions between zones, including transmission constraints and cross-zone trading.

This tutorial aims to provide a deeper understanding of how market zone coupling operates within ASSUME. While the framework handles much of the complexity internally, we will explore the underlying processes, such as the calculation of transmission capacities and the market clearing optimization. This detailed walkthrough is designed to enhance your comprehension of the framework’s capabilities and the dynamics of multi-zone electricity markets.

Throughout this tutorial, you will:

  • Define Multiple Market Zones: Segment the market into distinct zones based on geographical or operational criteria.

  • Configure Transmission Lines: Establish connections that allow energy flow between different market zones.

  • Understand the Market Clearing Process: Examine how the market clearing algorithm accounts for interactions and constraints across zones.

By the end of this workshop, you will not only know how to set up market zone coupling in ASSUME but also gain insights into the internal mechanisms that drive market interactions and price formations across different zones.

2. Setting Up the ASSUME Framework for Market Zone Coupling#

Before diving into market zone coupling, ensure that you have the ASSUME framework installed and set up correctly. If you haven’t done so already, follow the steps below to install the ASSUME core package and clone the repository containing predefined scenarios.

Note: If you already have the ASSUME framework installed and the repository cloned, you can skip executing the following code cells.

[ ]:
# Install the ASSUME framework
!pip install assume-framework

# Install Plotly if not already installed
!pip install plotly

Let’s also import some basic libraries that we will use throughout the tutorial.

[2]:
import pandas as pd

# import plotly for visualization
import plotly.graph_objects as go

# import yaml for reading and writing YAML files
import yaml

# Function to display DataFrame in Jupyter
from IPython.display import display

3. Understanding the Market Clearing Optimization#

Market clearing is a crucial component of electricity market simulations. It involves determining the optimal dispatch of supply and demand bids to maximize social welfare while respecting network constraints.

In the context of market zone coupling, the market clearing process must account for:

  • Connection Between Zones: Transmission lines that allow energy flow between different market zones.

  • Constraints: Limits on transmission capacities and ensuring energy balance within and across zones.

  • Bid Assignment: Properly assigning bids to their respective zones and considering cross-zone trading.

  • Price Extraction: Determining market prices for each zone based on the cleared bids and network constraints.

The ASSUME framework uses Pyomo to formulate and solve the market clearing optimization problem. Below is a simplified version of the market clearing function, highlighting key components related to zone coupling.

Simplified Market Clearing Optimization Problem#

We consider a simplified market clearing optimization model focusing on zone coupling, aiming to minimize the total cost.

Sets and Variables:#

  • \(T\): Set of time periods.

  • \(N\): Set of nodes (zones).

  • \(L\): Set of lines.

  • \(x_o \in [0, 1]\): Bid acceptance ratio for order \(o\).

  • \(f_{t, l} \in \mathbb{R}\): Power flow on line \(l\) at time \(t\).

Constants:#

  • \(P_o\): Price of order \(o\).

  • \(V_o\): Volume of order \(o\).

  • \(S_l\): Nominal capacity of line \(l\).

Objective Function:#

Minimize the total cost of accepted orders:

\[\min \sum_{o \in O} P_o V_o x_o\]

Constraints:#

  1. Energy Balance for Each Node and Time Period:

\[\begin{split}\sum_{\substack{o \in O \\ \text{node}(o) = n \\ \text{time}(o) = t}} V_o x_o + \sum_{l \in L} I_{n, l} f_{t, l} = 0 \quad \forall n \in N, \, t \in T\end{split}\]

Where: - \(I_{n, l}\) is the incidence value for node \(n\) and line \(l\) (from the incidence matrix).

  1. Transmission Capacity Constraints for Each Line and Time Period:

\[-S_l \leq f_{t, l} \leq S_l \quad \forall l \in L, \, t \in T\]

Summary:#

The goal is to minimize the total cost while ensuring energy balance at each node and respecting transmission line capacity limits for each time period.

In actual ASSUME Framework, the optimization problem is more complex and includes additional constraints and variables, and supports also additional bid types such as block and linked orders. However, the simplified model above captures the essence of market clearing with zone coupling.

[3]:
import pyomo.environ as pyo
from pyomo.opt import SolverFactory, TerminationCondition


def simplified_market_clearing_opt(orders, incidence_matrix, lines):
    """
    Simplified market clearing optimization focusing on zone coupling.

    Args:
        orders (dict): Dictionary of orders with bid_id as keys.
        lines (DataFrame): DataFrame containing information about the transmission lines.
        incidence_matrix (DataFrame): Incidence matrix describing the network structure.

    Returns:
        model (ConcreteModel): The solved Pyomo model.
        results (SolverResults): The solver results.
    """
    nodes = list(incidence_matrix.index)
    line_ids = list(incidence_matrix.columns)

    model = pyo.ConcreteModel()
    # Define dual suffix
    model.dual = pyo.Suffix(direction=pyo.Suffix.IMPORT)

    # Define the set of time periods
    model.T = pyo.Set(
        initialize=sorted(set(order["time"] for order in orders.values())),
        doc="timesteps",
    )
    # Define the set of nodes (zones)
    model.nodes = pyo.Set(initialize=nodes, doc="nodes")
    # Define the set of lines
    model.lines = pyo.Set(initialize=line_ids, doc="lines")

    # Decision variables for bid acceptance ratios (0 to 1)
    model.x = pyo.Var(
        orders.keys(),
        domain=pyo.NonNegativeReals,
        bounds=(0, 1),
        doc="bid_acceptance_ratio",
    )

    # Decision variables for power flows on each line at each time period
    model.flows = pyo.Var(model.T, model.lines, domain=pyo.Reals, doc="power_flows")

    # Energy balance constraint for each node and time period
    def energy_balance_rule(model, node, t):
        balance_expr = 0.0
        # Add contributions from orders
        for order_key, order in orders.items():
            if order["node"] == node and order["time"] == t:
                balance_expr += order["volume"] * model.x[order_key]

        # Add contributions from line flows based on the incidence matrix
        if incidence_matrix is not None:
            for line in model.lines:
                incidence_value = incidence_matrix.loc[node, line]
                if incidence_value != 0:
                    balance_expr += incidence_value * model.flows[t, line]

        return balance_expr == 0

    model.energy_balance = pyo.Constraint(
        model.nodes, model.T, rule=energy_balance_rule
    )

    # Transmission capacity constraints for each line and time period
    def transmission_capacity_rule(model, t, line):
        """
        Limits the power flow on each line based on its capacity.
        """
        capacity = lines.at[line, "s_nom"]
        return (-capacity, model.flows[t, line], capacity)

    # Apply transmission capacity constraints to all lines and time periods
    model.transmission_constraints = pyo.Constraint(
        model.T, model.lines, rule=transmission_capacity_rule
    )

    # Objective: Minimize total cost (sum of bid prices multiplied by accepted volumes)
    model.objective = pyo.Objective(
        expr=sum(orders[o]["price"] * orders[o]["volume"] * model.x[o] for o in orders),
        sense=pyo.minimize,
        doc="Total Cost Minimization",
    )

    # Choose the solver (HIGHS is used here)
    solver = SolverFactory("appsi_highs")
    results = solver.solve(model)

    # Check if the solver found an optimal solution
    if results.solver.termination_condition != TerminationCondition.optimal:
        raise Exception("Solver did not find an optimal solution.")

    return model, results

The above function is a simplified representation focusing on the essential aspects of market zone coupling. In the following sections, we will delve deeper into creating input files and mimicking the market clearing process using this function to understand the inner workings of the ASSUME framework.

4. Creating Exemplary Input Files for Market Zone Coupling#

To implement market zone coupling, users need to prepare specific input files that define the network’s structure, units, and demand profiles. Below, we will guide you through creating the necessary DataFrames for buses, transmission lines, power plant units, demand units, and demand profiles.

4.1. Defining Buses and Zones#

Buses represent nodes in the network where energy can be injected or withdrawn. Each bus is assigned to a zone, which groups buses into market areas. This zoning facilitates market coupling by managing interactions between different market regions.

[4]:
# @title Define the buses DataFrame with three nodes and two zones
buses = pd.DataFrame(
    {
        "name": ["north_1", "north_2", "south"],
        "v_nom": [380.0, 380.0, 380.0],
        "zone_id": ["DE_1", "DE_1", "DE_2"],
        "x": [10.0, 9.5, 11.6],
        "y": [54.0, 53.5, 48.1],
    }
).set_index("name")

# Display the buses DataFrame
print("Buses DataFrame:")
display(buses)
Buses DataFrame:
v_nom zone_id x y
name
north_1 380.0 DE_1 10.0 54.0
north_2 380.0 DE_1 9.5 53.5
south 380.0 DE_2 11.6 48.1

Explanation:

  • name: Identifier for each bus (north_1, north_2, and south).

  • v_nom: Nominal voltage level (in kV) for all buses.

  • zone_id: Identifier for the market zone to which the bus belongs (DE_1 for north buses and DE_2 for the south bus).

  • x, y: Geographical coordinates (optional, can be used for mapping or spatial analyses).

4.2. Configuring Transmission Lines#

Transmission Lines connect buses, allowing energy to flow between them. Each line has a specified capacity and electrical parameters.

[5]:
# @title Define three transmission lines
lines = pd.DataFrame(
    {
        "name": ["Line_N1_S", "Line_N2_S", "Line_N1_N2"],
        "bus0": ["north_1", "north_2", "north_1"],
        "bus1": ["south", "south", "north_2"],
        "s_nom": [5000.0, 5000.0, 5000.0],
        "x": [0.01, 0.01, 0.01],
        "r": [0.001, 0.001, 0.001],
    }
).set_index("name")

print("Transmission Lines DataFrame:")
display(lines)
Transmission Lines DataFrame:
bus0 bus1 s_nom x r
name
Line_N1_S north_1 south 5000.0 0.01 0.001
Line_N2_S north_2 south 5000.0 0.01 0.001
Line_N1_N2 north_1 north_2 5000.0 0.01 0.001

Explanation:

  • name: Identifier for each transmission line (Line_N1_S, Line_N2_S, and Line_N1_N2).

  • bus0, bus1: The two buses that the line connects.

  • s_nom: Nominal apparent power capacity of the line (in MVA).

  • x: Reactance of the line (in per unit).

  • r: Resistance of the line (in per unit).

4.3. Setting Up Power Plant and Demand Units#

Power Plant Units represent energy generation sources, while Demand Units represent consumption. Each unit is associated with a specific bus (node) and has operational parameters that define its behavior in the market.

[6]:
# @title Create the power plant units DataFrame

# Define the total number of units
num_units = 30  # Reduced for simplicity

# Generate the 'name' column: Unit 1 to Unit 30
names = [f"Unit {i}" for i in range(1, num_units + 1)]

# All other columns with constant values
technology = ["nuclear"] * num_units
bidding_zonal = ["naive_eom"] * num_units
fuel_type = ["uranium"] * num_units
emission_factor = [0.0] * num_units
max_power = [1000.0] * num_units
min_power = [0.0] * num_units
efficiency = [0.3] * num_units

# Generate 'additional_cost':
# - North units (1-15): 5 to 19
# - South units (16-30): 20 to 34
additional_cost = list(range(5, 5 + num_units))

# Initialize 'node' and 'unit_operator' lists
node = []
unit_operator = []

for i in range(1, num_units + 1):
    if 1 <= i <= 8:
        node.append("north_1")  # All north units connected to 'north_1'
        unit_operator.append("Operator North")
    elif 9 <= i <= 15:
        node.append("north_2")
        unit_operator.append("Operator North")
    else:
        node.append("south")  # All south units connected to 'south'
        unit_operator.append("Operator South")

# Create the DataFrame
powerplant_units = pd.DataFrame(
    {
        "name": names,
        "technology": technology,
        "bidding_zonal": bidding_zonal,  # bidding strategy used to bid on the zonal market. Should be same name as in config file
        "fuel_type": fuel_type,
        "emission_factor": emission_factor,
        "max_power": max_power,
        "min_power": min_power,
        "efficiency": efficiency,
        "additional_cost": additional_cost,
        "node": node,
        "unit_operator": unit_operator,
    }
)

print("Power Plant Units DataFrame:")
display(powerplant_units.head())
Power Plant Units DataFrame:
name technology bidding_zonal fuel_type emission_factor max_power min_power efficiency additional_cost node unit_operator
0 Unit 1 nuclear naive_eom uranium 0.0 1000.0 0.0 0.3 5 north_1 Operator North
1 Unit 2 nuclear naive_eom uranium 0.0 1000.0 0.0 0.3 6 north_1 Operator North
2 Unit 3 nuclear naive_eom uranium 0.0 1000.0 0.0 0.3 7 north_1 Operator North
3 Unit 4 nuclear naive_eom uranium 0.0 1000.0 0.0 0.3 8 north_1 Operator North
4 Unit 5 nuclear naive_eom uranium 0.0 1000.0 0.0 0.3 9 north_1 Operator North
  • Power Plant Units:

    • name: Identifier for each power plant unit (Unit 1 to Unit 30).

    • technology: Type of technology (nuclear for all units).

    • bidding_nodal: Bidding strategy used (naive_eom for all units).

    • fuel_type: Type of fuel used (uranium for all units).

    • emission_factor: Emissions per unit of energy produced (0.0 for all units).

    • max_power, min_power: Operational power limits (1000.0 MW max, 0.0 MW min for all units).

    • efficiency: Conversion efficiency (0.3 for all units).

    • additional_cost: Additional operational costs (5 to 34, with southern units being more expensive).

    • node: The bus (zone) to which the unit is connected (north_1 for units 1-15, south for units 16-30).

    • unit_operator: Operator responsible for the unit (Operator North for northern units, Operator South for southern units).

[7]:
# @title Define the demand units
demand_units = pd.DataFrame(
    {
        "name": ["demand_north_1", "demand_north_2", "demand_south"],
        "technology": ["inflex_demand"] * 3,
        "bidding_zonal": ["naive_eom"] * 3,
        "max_power": [100000, 100000, 100000],
        "min_power": [0, 0, 0],
        "unit_operator": ["eom_de"] * 3,
        "node": ["north_1", "north_2", "south"],
    }
)

# Display the demand_units DataFrame
print("Demand Units DataFrame:")
display(demand_units)
Demand Units DataFrame:
name technology bidding_zonal max_power min_power unit_operator node
0 demand_north_1 inflex_demand naive_eom 100000 0 eom_de north_1
1 demand_north_2 inflex_demand naive_eom 100000 0 eom_de north_2
2 demand_south inflex_demand naive_eom 100000 0 eom_de south
  • Demand Units:

    • name: Identifier for each demand unit (demand_north_1, demand_north_2, and demand_south).

    • technology: Type of demand (inflex_demand for all units).

    • bidding_zonal: Bidding strategy used (naive_eom for all units).

    • max_power, min_power: Operational power limits (100000 MW max, 0 MW min for all units).

    • unit_operator: Operator responsible for the unit (eom_de for all units).

    • node: The bus (zone) to which the unit is connected (north_1, north_2, and south).

4.4. Preparing Demand Data#

Demand Data provides the expected electricity demand for each demand unit over time. This data is essential for simulating how demand varies and affects market dynamics.

[8]:
# @title Define the demand DataFrame

# the demand for the north_1 and north_2 zones increases by 400 MW per hour
# while the demand for the south zone decreases by 600 MW per hour
# the demand starts at 2400 MW for the north zones and 17400 MW for the south zone
demand_df = pd.DataFrame(
    {
        "datetime": pd.date_range(start="2019-01-01", periods=24, freq="h"),
        "demand_north_1": [2400 + i * 400 for i in range(24)],
        "demand_north_2": [2400 + i * 400 for i in range(24)],
        "demand_south": [17400 - i * 600 for i in range(24)],
    }
)

# Convert the 'datetime' column to datetime objects and set as index
demand_df.set_index("datetime", inplace=True)

# Display the demand_df DataFrame
print("Demand DataFrame:")
display(demand_df.head())
Demand DataFrame:
demand_north_1 demand_north_2 demand_south
datetime
2019-01-01 00:00:00 2400 2400 17400
2019-01-01 01:00:00 2800 2800 16800
2019-01-01 02:00:00 3200 3200 16200
2019-01-01 03:00:00 3600 3600 15600
2019-01-01 04:00:00 4000 4000 15000

Explanation:

  • datetime: Timestamp for each demand value.

  • demand_north_1, demand_north_2, demand_south: Demand values for each respective demand unit.

Note: The demand timeseries has been designed to be fulfillable by the defined power plants in both zones.

5. Reproducing the Market Clearing Process#

With the input files prepared, we can now reproduce the market clearing process using the simplified market clearing function. This will help us understand how different market zones interact, how constraints are managed, how bids are assigned, and how market prices are extracted.

5.1. Calculating the Incidence Matrix#

The Incidence Matrix represents the connection relationships between different nodes in a network. In the context of market zones, it indicates which transmission lines connect which zones. The incidence matrix is a binary matrix where each element denotes whether a particular node is connected to a line or not. This matrix is essential for understanding the structure of the transmission network and for formulating power flow equations during the market clearing process.

[10]:
# @title Create the incidence matrix
def create_incidence_matrix(lines, buses, zones_id=None):
    # Determine nodes based on whether we're working with zones or individual buses
    if zones_id:
        nodes = buses[zones_id].unique()  # Use zones as nodes
        node_mapping = buses[zones_id].to_dict()  # Map bus IDs to zones
    else:
        nodes = buses.index.values  # Use buses as nodes
        node_mapping = {bus: bus for bus in nodes}  # Identity mapping for buses

    # Use the line indices as columns for the incidence matrix
    line_indices = lines.index.values

    # Initialize incidence matrix as a DataFrame for easier label-based indexing
    incidence_matrix = pd.DataFrame(0, index=nodes, columns=line_indices)

    # Fill in the incidence matrix by iterating over lines
    for line_idx, line in lines.iterrows():
        bus0 = line["bus0"]
        bus1 = line["bus1"]

        # Retrieve mapped nodes (zones or buses)
        node0 = node_mapping.get(bus0)
        node1 = node_mapping.get(bus1)

        # Ensure both nodes are valid and part of the defined nodes
        if (
            node0 is not None
            and node1 is not None
            and node0 in nodes
            and node1 in nodes
        ):
            if node0 != node1:  # Only create incidence for different nodes
                # Set incidence values: +1 for the "from" node and -1 for the "to" node
                incidence_matrix.at[node0, line_idx] = (
                    1  # Outgoing from bus0 (or zone0)
                )
                incidence_matrix.at[node1, line_idx] = -1  # Incoming to bus1 (or zone1)

    # Return the incidence matrix as a DataFrame
    return incidence_matrix


# Calculate the incidence matrix
incidence_matrix = create_incidence_matrix(lines, buses, "zone_id")

print("Calculated Incidence Matrix between Zones:")
display(incidence_matrix)
Calculated Incidence Matrix between Zones:
Line_N1_S Line_N2_S Line_N1_N2
DE_1 1 1 0
DE_2 -1 -1 0

Explanation:

  • Nodes (Zones): Extracted from the buses DataFrame (DE_1 and DE_2).

  • Transmission Lines: Iterated over to sum their capacities between different zones.

  • Bidirectional Flow Assumption: Transmission capacities are added in both directions (DE_1 -> DE_2 and DE_2 -> DE_1).

  • Lower Triangle Negative Values: To indicate the opposite direction of power flow, capacities in the lower triangle of the matrix are converted to negative values.

5.2. Creating and Mapping Market Orders#

We will construct a dictionary of market orders representing supply and demand bids from power plants and demand units. The orders include details such as price, volume, location (node), and time. Once the orders are generated, they will be mapped from nodes to corresponding zones using a pre-defined node-to-zone mapping.

[11]:
# @title Construct Orders and Map Nodes to Zones
# Initialize orders dictionary
orders = {}

# Add power plant bids
for _, row in powerplant_units.iterrows():
    bid_id = row["name"]
    for timestamp in demand_df.index:
        orders[f"{bid_id}_{timestamp}"] = {
            "price": row["additional_cost"],  # Assuming additional_cost as bid price
            "volume": row["max_power"],  # Assuming max_power as bid volume
            "node": row["node"],
            "time": timestamp,
        }

# Add demand bids
for _, row in demand_units.iterrows():
    bid_id = row["name"]
    for timestamp in demand_df.index:
        orders[f"{bid_id}_{timestamp}"] = {
            "price": 100,  # Demand bids with high price
            "volume": -demand_df.loc[
                timestamp, row["name"]
            ],  # Negative volume for demand
            "node": row["node"],
            "time": timestamp,
        }

# Display a sample order
print("\nSample Supply Order:")
display(orders["Unit 1_2019-01-01 00:00:00"])

print("\nSample Demand Order:")
display(orders["demand_north_1_2019-01-01 00:00:00"])

Sample Supply Order:
{'price': 5,
 'volume': 1000.0,
 'node': 'north_1',
 'time': Timestamp('2019-01-01 00:00:00')}

Sample Demand Order:
{'price': 100,
 'volume': -2400,
 'node': 'north_1',
 'time': Timestamp('2019-01-01 00:00:00')}
[12]:
# @title Map the orders to zones
# Create a mapping from node_id to zone_id
node_mapping = buses["zone_id"].to_dict()

# Create a new dictionary with mapped zone IDs
orders_mapped = {}
for bid_id, bid in orders.items():
    original_node = bid["node"]
    mapped_zone = node_mapping.get(
        original_node, original_node
    )  # Default to original_node if not found
    orders_mapped[bid_id] = {
        "price": bid["price"],
        "volume": bid["volume"],
        "node": mapped_zone,  # Replace bus with zone ID
        "time": bid["time"],
    }

# Display the mapped orders
print("Mapped Orders:")
display(pd.DataFrame(orders_mapped).T.head())
Mapped Orders:
price volume node time
Unit 1_2019-01-01 00:00:00 5 1000.0 DE_1 2019-01-01 00:00:00
Unit 1_2019-01-01 01:00:00 5 1000.0 DE_1 2019-01-01 01:00:00
Unit 1_2019-01-01 02:00:00 5 1000.0 DE_1 2019-01-01 02:00:00
Unit 1_2019-01-01 03:00:00 5 1000.0 DE_1 2019-01-01 03:00:00
Unit 1_2019-01-01 04:00:00 5 1000.0 DE_1 2019-01-01 04:00:00

Explanation:

  • Power Plant Bids: Each power plant unit submits a bid for each time period with its additional_cost as the bid price and max_power as the bid volume.

  • Demand Bids: Each demand unit submits a bid for each time period with a high price (set to 100) and a negative volume representing the demand.

  • Node to Zone Mapping: After creating the bids, the node information is mapped to corresponding zones for further market clearing steps. The mapping uses a pre-defined dictionary (node_mapping) to replace each node ID with the corresponding zone ID. In ASSUME, this mapping happens automatically on the market side, but we are simulating it here for educational purposes.

5.3. Running the Market Clearing Simulation#

We will conduct three simulations:

  1. Simulation 1: Transmission capacities between DE_1 (north) and DE_2 (south) are zero.

  2. Simulation 2: Transmission capacities between DE_1 (north) and DE_2 (south) are medium.

  3. Simulation 3: Transmission capacities between DE_1 (north) and DE_2 (south) are high.

Simulation 1: Zero Transmission Capacity Between Zones#

[36]:
print("### Simulation 1: Zero Transmission Capacity Between Zones")

lines_sim1 = lines.copy()
lines_sim1["s_nom"] = 0  # Set transmission capacity to zero for all lines

print("Transmission Lines for Simulation 1:")
display(lines_sim1)

# Run the simplified market clearing for Simulation 1
model_sim1, results_sim1 = simplified_market_clearing_opt(
    orders=orders_mapped,
    incidence_matrix=incidence_matrix,
    lines=lines_sim1,
)
### Simulation 1: Zero Transmission Capacity Between Zones
Transmission Lines for Simulation 1:
bus0 bus1 s_nom x r
name
Line_N1_S north_1 south 0 0.01 0.001
Line_N2_S north_2 south 0 0.01 0.001
Line_N1_N2 north_1 north_2 0 0.01 0.001

Simulation 2: Medium Transmission Capacity Between Zones#

[15]:
print("### Simulation 2: Medium Transmission Capacity Between Zones")

# Define the lines for Simulation 2 with medium transmission capacity
lines_sim2 = lines.copy()
lines_sim2["s_nom"] = 3000.0  # Set transmission capacity to 3000 MW for all lines

# Display the incidence matrix for Simulation 2
print("Transmission Lines for Simulation 2:")
display(lines_sim2)

# Run the simplified market clearing for Simulation 2
model_sim2, results_sim2 = simplified_market_clearing_opt(
    orders=orders_mapped,
    incidence_matrix=incidence_matrix,
    lines=lines_sim2,
)
### Simulation 2: Medium Transmission Capacity Between Zones
Transmission Lines for Simulation 2:
bus0 bus1 s_nom x r
name
Line_N1_S north_1 south 3000.0 0.01 0.001
Line_N2_S north_2 south 3000.0 0.01 0.001
Line_N1_N2 north_1 north_2 3000.0 0.01 0.001

Simulation 3: High Transmission Capacity Between Zones#

[16]:
print("### Simulation 3: High Transmission Capacity Between Zones")

# Define the lines for Simulation 3 with high transmission capacity
lines_sim3 = lines.copy()
lines_sim3["s_nom"] = 5000.0  # Set transmission capacity to 5000 MW for all lines

# Display the line capacities for Simulation 3
print("Transmission Lines for Simulation 3:")
display(lines_sim3)

# Run the simplified market clearing for Simulation 3
model_sim3, results_sim3 = simplified_market_clearing_opt(
    orders=orders_mapped,
    incidence_matrix=incidence_matrix,
    lines=lines_sim3,
)
### Simulation 3: High Transmission Capacity Between Zones
Transmission Lines for Simulation 3:
bus0 bus1 s_nom x r
name
Line_N1_S north_1 south 5000.0 0.01 0.001
Line_N2_S north_2 south 5000.0 0.01 0.001
Line_N1_N2 north_1 north_2 5000.0 0.01 0.001

5.4. Extracting and Interpreting the Results#

After running all three simulations, we can extract the results to understand how the presence or absence of transmission capacity affects bid acceptances and power flows between zones.

Extracting Clearing Prices#

The clearing prices for each market zone and time period are extracted using the dual variables associated with the energy balance constraints in the optimization model. Specifically, the dual variable of the energy balance constraint for a given zone and time period represents the marginal price of electricity in that zone at that time.

In the extract_results function, the following steps are performed to obtain the clearing prices:

  1. Energy Balance Constraints: For each zone and time period, the energy balance equation ensures that the total supply plus imports minus exports equals the demand.

  2. Dual Variables: The dual variable (model.dual[model.energy_balance[node, t]]) associated with each energy balance constraint captures the sensitivity of the objective function (total cost) to a marginal increase in demand or supply.

  3. Clearing Price Interpretation: The value of the dual variable corresponds to the clearing price in the respective zone and time period, reflecting the cost of supplying an additional unit of electricity.

This method leverages the duality in optimization to efficiently extract market prices resulting from the optimal dispatch of bids under the given constraints.

[17]:
# @title Function to extract market clearing results from the optimization model
def extract_results(model, incidence_matrix):
    nodes = list(incidence_matrix.index)
    lines = list(incidence_matrix.columns)

    # Extract accepted bid ratios using a dictionary comprehension
    accepted_bids = {
        o: pyo.value(model.x[o]) for o in model.x if pyo.value(model.x[o]) > 0
    }

    # Extract power flows on each line for each time period
    power_flows = [
        {"time": t, "line": line, "flow_MW": pyo.value(model.flows[t, line])}
        for t in model.T
        for line in lines
        if pyo.value(model.flows[t, line]) != 0
    ]
    power_flows_df = pd.DataFrame(power_flows)

    # Extract market clearing prices from dual variables
    clearing_prices = [
        {
            "zone": node,
            "time": t,
            "clearing_price": pyo.value(model.dual[model.energy_balance[node, t]]),
        }
        for node in nodes
        for t in model.T
    ]
    clearing_prices_df = pd.DataFrame(clearing_prices)

    return accepted_bids, power_flows_df, clearing_prices_df
[18]:
# Extract results for Simulation 1
accepted_bids_sim1, power_flows_df_sim1, clearing_prices_df_sim1 = extract_results(
    model_sim1, incidence_matrix
)
[19]:
print("Simulation 1: Power Flows Between Zones")
display(power_flows_df_sim1.head())
Simulation 1: Power Flows Between Zones

As it is to be expected, there are no flows printed since there is no transfer capacity available.

[20]:
print("Simulation 1: Clearing Prices per Zone and Time")
display(clearing_prices_df_sim1.loc[clearing_prices_df_sim1["zone"] == "DE_1"].head())
display(clearing_prices_df_sim1.loc[clearing_prices_df_sim1["zone"] == "DE_2"].head())
Simulation 1: Clearing Prices per Zone and Time
zone time clearing_price
0 DE_1 2019-01-01 00:00:00 9.0
1 DE_1 2019-01-01 01:00:00 10.0
2 DE_1 2019-01-01 02:00:00 11.0
3 DE_1 2019-01-01 03:00:00 12.0
4 DE_1 2019-01-01 04:00:00 12.0
zone time clearing_price
24 DE_2 2019-01-01 00:00:00 100.0
25 DE_2 2019-01-01 01:00:00 100.0
26 DE_2 2019-01-01 02:00:00 100.0
27 DE_2 2019-01-01 03:00:00 100.0
28 DE_2 2019-01-01 04:00:00 100.0
[21]:
# Extract results for Simulation 2
accepted_bids_sim2, power_flows_df_sim2, clearing_prices_df_sim2 = extract_results(
    model_sim2, incidence_matrix
)
[22]:
print("Simulation 2: Power Flows Between Zones")
display(power_flows_df_sim2.head())
Simulation 2: Power Flows Between Zones
time line flow_MW
0 2019-01-01 00:00:00 Line_N1_S -3000.0
1 2019-01-01 00:00:00 Line_N2_S -3000.0
2 2019-01-01 00:00:00 Line_N1_N2 -3000.0
3 2019-01-01 01:00:00 Line_N1_S -3000.0
4 2019-01-01 01:00:00 Line_N2_S -3000.0
[23]:
print("Simulation 2: Clearing Prices per Zone and Time")
display(clearing_prices_df_sim2.loc[clearing_prices_df_sim2["zone"] == "DE_1"].head())
display(clearing_prices_df_sim2.loc[clearing_prices_df_sim2["zone"] == "DE_2"].head())
Simulation 2: Clearing Prices per Zone and Time
zone time clearing_price
0 DE_1 2019-01-01 00:00:00 15.0
1 DE_1 2019-01-01 01:00:00 16.0
2 DE_1 2019-01-01 02:00:00 17.0
3 DE_1 2019-01-01 03:00:00 18.0
4 DE_1 2019-01-01 04:00:00 19.0
zone time clearing_price
24 DE_2 2019-01-01 00:00:00 31.0
25 DE_2 2019-01-01 01:00:00 30.0
26 DE_2 2019-01-01 02:00:00 30.0
27 DE_2 2019-01-01 03:00:00 29.0
28 DE_2 2019-01-01 04:00:00 29.0
[24]:
# Extract results for Simulation 3
accepted_bids_sim3, power_flows_df_sim3, clearing_prices_df_sim3 = extract_results(
    model_sim3, incidence_matrix
)
[25]:
print("Simulation 3: Power Flows Between Zones")
display(power_flows_df_sim3.head())
Simulation 3: Power Flows Between Zones
time line flow_MW
0 2019-01-01 00:00:00 Line_N1_S -5000.0
1 2019-01-01 00:00:00 Line_N2_S -5000.0
2 2019-01-01 00:00:00 Line_N1_N2 -5000.0
3 2019-01-01 01:00:00 Line_N1_S -4400.0
4 2019-01-01 01:00:00 Line_N2_S -5000.0
[26]:
print("Simulation 3: Clearing Prices per Zone and Time")
display(clearing_prices_df_sim3.loc[clearing_prices_df_sim3["zone"] == "DE_1"].head())
display(clearing_prices_df_sim3.loc[clearing_prices_df_sim3["zone"] == "DE_2"].head())
Simulation 3: Clearing Prices per Zone and Time
zone time clearing_price
0 DE_1 2019-01-01 00:00:00 19.0
1 DE_1 2019-01-01 01:00:00 27.0
2 DE_1 2019-01-01 02:00:00 27.0
3 DE_1 2019-01-01 03:00:00 27.0
4 DE_1 2019-01-01 04:00:00 28.0
zone time clearing_price
24 DE_2 2019-01-01 00:00:00 27.0
25 DE_2 2019-01-01 01:00:00 27.0
26 DE_2 2019-01-01 02:00:00 27.0
27 DE_2 2019-01-01 03:00:00 27.0
28 DE_2 2019-01-01 04:00:00 28.0

Explanation:

  • Accepted Bids: Shows which bids were accepted in each simulation and the ratio at which they were accepted.

  • Power Flows: Indicates the amount of energy transmitted between zones. In Simulation 1, with zero transmission capacity, there should be no power flows between DE_1 and DE_2. In Simulation 2 and 3, with medium and high transmission capacities, power flows can occur between zones.

  • Clearing Prices: Represents the average bid price in each zone at each time period. Comparing prices across simulations can reveal the impact of transmission capacity on market prices.

5.5. Comparing Simulations#

To better understand the impact of transmission capacity, let’s compare the key results from all three simulations.

[27]:
# @title Plot the market clearing prices for each zone and simulation run
# Initialize the Plotly figure
fig = go.Figure()

# Iterate over each zone to plot clearing prices for all three simulations
for zone in incidence_matrix.index:
    # Filter data for the current zone and Simulation 1
    zone_prices_sim1 = clearing_prices_df_sim1[clearing_prices_df_sim1["zone"] == zone]
    # Filter data for the current zone and Simulation 2
    zone_prices_sim2 = clearing_prices_df_sim2[clearing_prices_df_sim2["zone"] == zone]
    # Filter data for the current zone and Simulation 3
    zone_prices_sim3 = clearing_prices_df_sim3[clearing_prices_df_sim3["zone"] == zone]

    # Add trace for Simulation 1
    fig.add_trace(
        go.Scatter(
            x=zone_prices_sim1["time"],
            y=zone_prices_sim1["clearing_price"],
            mode="lines",
            name=f"{zone} - Sim1 (Zero Capacity)",
            line=dict(dash="dash"),  # Dashed line for Simulation 1
        )
    )

    # Add trace for Simulation 2
    fig.add_trace(
        go.Scatter(
            x=zone_prices_sim2["time"],
            y=zone_prices_sim2["clearing_price"],
            mode="lines",
            name=f"{zone} - Sim2 (Medium Capacity)",
            line=dict(dash="dot"),  # Dotted line for Simulation 2
        )
    )

    # Add trace for Simulation 3
    fig.add_trace(
        go.Scatter(
            x=zone_prices_sim3["time"],
            y=zone_prices_sim3["clearing_price"],
            mode="lines",
            name=f"{zone} - Sim3 (High Capacity)",
            line=dict(dash="solid"),  # Solid line for Simulation 3
        )
    )

# Update layout for better aesthetics and interactivity
fig.update_layout(
    title="Clearing Prices per Zone Over Time: Sim1, Sim2, & Sim3",
    xaxis_title="Time",
    yaxis_title="Clearing Price",
    legend_title="Simulations",
    xaxis=dict(
        tickangle=45,
        type="date",  # Ensure the x-axis is treated as dates
    ),
    hovermode="x unified",  # Unified hover for better comparison
    template="plotly_white",  # Clean white background
    width=1000,
    height=600,
)

# Display the interactive plot
fig.show()

Explanation:

  • Clearing Prices Plot: Shows how market prices vary over time for each zone across all three simulations. The dashed lines represent Simulation 1 (no transmission capacity), dotted lines represent Simulation 2 (medium transmission capacity), and solid lines represent Simulation 3 (high transmission capacity). This visualization helps in observing how the presence of transmission capacity affects price convergence or divergence between zones.

6. Execution with ASSUME#

In a real-world scenario, the ASSUME framework handles the reading of CSV files and the configuration of the simulation through configuration files. For the purpose of this tutorial, we’ll integrate our prepared data and configuration into ASSUME to execute the simulation seamlessly.

Step 1: Saving Input Files#

We will save the generated input DataFrames to the inputs/tutorial_08 folder. The required files are: - demand_units.csv - demand_df.csv - powerplant_units.csv - buses.csv - lines.csv

Additionally, we’ll create a new file fuel_prices.csv.

Note: The demand timeseries has been extended to cover 48 hours as ASSUME always requires an additional day of data for the market simulation.

Create the Inputs Directory and Save CSV Files#

[28]:
import os

# Define the input directory
input_dir = "inputs/tutorial_08"

# Create the directory if it doesn't exist
os.makedirs(input_dir, exist_ok=True)

# extend demand_df for another day with the same demand profile
demand_df = pd.concat([demand_df, demand_df])
demand_df.index = pd.date_range(start="2019-01-01", periods=48, freq="h")

# Save the DataFrames to CSV files
buses.to_csv(os.path.join(input_dir, "buses.csv"), index=True)
lines.to_csv(os.path.join(input_dir, "lines.csv"), index=True)
powerplant_units.to_csv(os.path.join(input_dir, "powerplant_units.csv"), index=False)
demand_units.to_csv(os.path.join(input_dir, "demand_units.csv"), index=False)
demand_df.to_csv(os.path.join(input_dir, "demand_df.csv"))

print("Input CSV files have been saved to 'inputs/tutorial_08'.")
Input CSV files have been saved to 'inputs/tutorial_08'.
[29]:
# @title Create fuel prices
fuel_prices = {
    "fuel": ["uranium", "co2"],
    "price": [5, 25],
}

# Convert to DataFrame and save as CSV
fuel_prices_df = pd.DataFrame(fuel_prices).T
fuel_prices_df.to_csv(
    os.path.join(input_dir, "fuel_prices_df.csv"), index=True, header=False
)

print("Fuel Prices CSV file has been saved to 'inputs/tutorial_08/fuel_prices.csv'.")
Fuel Prices CSV file has been saved to 'inputs/tutorial_08/fuel_prices.csv'.

Step 2: Creating the Configuration YAML File#

The configuration file defines the simulation parameters, including market settings and network configurations. Below is the YAML configuration tailored for our tutorial.

Create config.yaml#

[30]:
config = {
    "zonal_case": {
        "start_date": "2019-01-01 00:00",
        "end_date": "2019-01-01 23:00",
        "time_step": "1h",
        "save_frequency_hours": 24,
        "markets_config": {
            "zonal": {
                "operator": "EOM_operator",
                "product_type": "energy",
                "products": [{"duration": "1h", "count": 1, "first_delivery": "1h"}],
                "opening_frequency": "1h",
                "opening_duration": "1h",
                "volume_unit": "MWh",
                "maximum_bid_volume": 100000,
                "maximum_bid_price": 3000,
                "minimum_bid_price": -500,
                "price_unit": "EUR/MWh",
                "market_mechanism": "pay_as_clear_complex",
                "additional_fields": ["bid_type", "node"],
                "param_dict": {"network_path": ".", "zones_identifier": "zone_id"},
            }
        },
    }
}

# Define the path for the config file
config_path = os.path.join(input_dir, "config.yaml")

# Save the configuration to a YAML file
with open(config_path, "w") as file:
    yaml.dump(config, file, sort_keys=False)

print(f"Configuration YAML file has been saved to '{config_path}'.")
Configuration YAML file has been saved to 'inputs/tutorial_08/config.yaml'.

Detailed Configuration Explanation#

The config.yaml file plays a key role in defining the simulation parameters. Below is a detailed explanation of each configuration parameter:

  • zonal_case:

    • start_date: The start date and time for the simulation (2019-01-01 00:00).

    • end_date: The end date and time for the simulation (2019-01-02 00:00).

    • time_step: The simulation time step (1h), indicating hourly intervals.

    • save_frequency_hours: How frequently the simulation results are saved (24 hours).

  • markets_config:

    • zonal: The name of the market. Remember, that our power plant units had a column named bidding_zonal. This is how a particluar bidding strategy is assigned to a particluar market.

      • operator: The market operator (EOM_operator).

      • product_type: Type of market product (energy).

      • products: List defining the market products:

        • duration: Duration of the product (1h).

        • count: Number of products (1).

        • first_delivery: When the first delivery occurs (1h).

      • opening_frequency: Frequency of market openings (1h).

      • opening_duration: Duration of market openings (1h).

      • volume_unit: Unit of volume measurement (MWh).

      • maximum_bid_volume: Maximum volume allowed per bid (100000 MWh).

      • maximum_bid_price: Maximum price allowed per bid (3000 EUR/MWh).

      • minimum_bid_price: Minimum price allowed per bid (-500 EUR/MWh).

      • price_unit: Unit of price measurement (EUR/MWh).

      • market_mechanism: The market clearing mechanism (pay_as_clear_complex).

      • additional_fields: Additional fields required for bids:

        • bid_type: Type of bid (e.g., supply or demand).

        • node: The market zone associated with the bid.

      • param_dict:

        • network_path: Path to the network files (. indicates current directory).

        • zones_identifier: Identifier used for market zones (zone_id).

This configuration ensures that the simulation accurately represents the zonal market dynamics, including bid restrictions and market operations.

Step 3: Running the Simulation#

With the input files and configuration in place, we can now run the simulation using ASSUME’s built-in functions.

Example Simulation Code#

[ ]:
# import the main World class and the load_scenario_folder functions from assume
from assume import World
from assume.scenario.loader_csv import load_scenario_folder

# Define paths for input and output data
csv_path = "outputs"

# Define the data format and database URI
# Use "local_db" for SQLite database or "timescale" for TimescaleDB in Docker

# Create directories if they don't exist
os.makedirs(csv_path, exist_ok=True)
os.makedirs("local_db", exist_ok=True)

data_format = "local_db"  # "local_db" or "timescale"

if data_format == "local_db":
    db_uri = "sqlite:///local_db/assume_db.db"
elif data_format == "timescale":
    db_uri = "postgresql://assume:assume@localhost:5432/assume"

# Create the World instance
world = World(database_uri=db_uri, export_csv_path=csv_path)

# Load the scenario by providing the world instance
# The path to the inputs folder and the scenario name (subfolder in inputs)
# and the study case name (which config to use for the simulation)
load_scenario_folder(
    world,
    inputs_path="inputs",
    scenario="tutorial_08",
    study_case="zonal_case",
)

# Run the simulation
world.run()

7. Analyzing the Results#

After running the simulation, you can analyze the results using the methods demonstrated in section 5. This integration with ASSUME allows for more extensive and scalable simulations, leveraging the framework’s capabilities for handling complex market scenarios.

In this section, we will:

  1. Locate the Simulation Output Files: Understand where the simulation results are saved.

  2. Load and Inspect the Output Data: Read the output CSV files and examine their structure.

  3. Plot Clearing Prices: Visualize the market clearing prices to compare with our previous manual simulations.

7.1. Locating the Simulation Output Files#

The simulation outputs are saved in the outputs/tutorial_08_zonal_case directory. Specifically, the key output file we’ll work with is market_meta.csv, which contains detailed information about the market outcomes for each zone and time period.

7.2. Loading and Inspecting the Output Data#

[32]:
# Define the path to the simulation output
output_dir = "outputs/tutorial_08_zonal_case"
market_meta_path = os.path.join(output_dir, "market_meta.csv")

# Load the market_meta.csv file
market_meta = pd.read_csv(market_meta_path, index_col="time", parse_dates=True)
# drop the first column
market_meta = market_meta.drop(columns=market_meta.columns[0])

# Display a sample of the data
print("Sample of market_meta.csv:")
display(market_meta.head())
Sample of market_meta.csv:
supply_volume demand_volume demand_volume_energy supply_volume_energy price max_price min_price node product_start product_end only_hours market_id simulation
time
2019-01-01 01:00:00 15000 5600 5600 15000 43.667 43.667 43.667 DE_1 2019-01-01 01:00:00 2019-01-01 02:00:00 NaN zonal tutorial_08_zonal_case
2019-01-01 01:00:00 7400 16800 16800 7400 43.667 43.667 43.667 DE_2 2019-01-01 01:00:00 2019-01-01 02:00:00 NaN zonal tutorial_08_zonal_case
2019-01-01 02:00:00 15000 6400 6400 15000 43.667 43.667 43.667 DE_1 2019-01-01 02:00:00 2019-01-01 03:00:00 NaN zonal tutorial_08_zonal_case
2019-01-01 02:00:00 7600 16200 16200 7600 43.667 43.667 43.667 DE_2 2019-01-01 02:00:00 2019-01-01 03:00:00 NaN zonal tutorial_08_zonal_case
2019-01-01 03:00:00 15000 7200 7200 15000 43.667 43.667 43.667 DE_1 2019-01-01 03:00:00 2019-01-01 04:00:00 NaN zonal tutorial_08_zonal_case

Explanation:

  • market_meta.csv: This file contains the market outcomes for each zone and time period, including supply and demand volumes, clearing prices, and other relevant metrics.

  • Columns:

    • supply_volume: Total volume supplied in the zone.

    • demand_volume: Total volume demanded in the zone.

    • demand_volume_energy: Energy demand volume (same as demand_volume for energy markets).

    • supply_volume_energy: Energy supply volume (same as supply_volume for energy markets).

    • price: Clearing price in the zone for the time period.

    • max_price: Maximum bid price accepted.

    • min_price: Minimum bid price accepted.

    • node: Identifier for the market zone (DE_1 or DE_2).

    • product_start: Start time of the market product.

    • product_end: End time of the market product.

    • only_hours: Indicator flag (not used in this context).

    • market_id: Identifier for the market (zonal).

    • time: Timestamp for the market product.

    • simulation: Identifier for the simulation case (tutorial_08_zonal_case).

7.3. Plotting Clearing Prices#

To verify that the simulation results align with our previous manual demonstrations, we’ll plot the clearing prices for each zone over time. This will help us observe how transmission capacities influence price convergence or divergence between zones.

Processing the Market Meta Data#

[33]:
# Extract unique zones
zones = market_meta["node"].unique()

# Initialize an empty DataFrame to store clearing prices per zone and time
clearing_prices_df = pd.DataFrame()

# Populate the DataFrame with clearing prices for each zone
for zone in zones:
    zone_data = market_meta[market_meta["node"] == zone][["price"]]
    zone_data = zone_data.rename(columns={"price": f"{zone}_price"})
    clearing_prices_df = (
        pd.merge(
            clearing_prices_df,
            zone_data,
            left_index=True,
            right_index=True,
            how="outer",
        )
        if not clearing_prices_df.empty
        else zone_data
    )

# Sort the DataFrame by time
clearing_prices_df = clearing_prices_df.sort_index()

# Display a sample of the processed clearing prices
print("Sample of Processed Clearing Prices:")
display(clearing_prices_df.head())
Sample of Processed Clearing Prices:
DE_1_price DE_2_price
time
2019-01-01 01:00:00 43.667 43.667
2019-01-01 02:00:00 43.667 43.667
2019-01-01 03:00:00 43.667 43.667
2019-01-01 04:00:00 44.667 44.667
2019-01-01 05:00:00 44.667 44.667
[35]:
# @title Plot market clearing prices
# Initialize the Plotly figure
fig = go.Figure()

# Iterate over each zone to plot clearing prices
for zone in zones:
    fig.add_trace(
        go.Scatter(
            x=clearing_prices_df.index,
            y=clearing_prices_df[f"{zone}_price"],
            mode="lines",
            name=f"{zone} - Simulation",
            line=dict(width=2),
        )
    )

# Update layout for better aesthetics and interactivity
fig.update_layout(
    title="Clearing Prices per Zone Over Time: Simulation Results",
    xaxis_title="Time",
    yaxis_title="Clearing Price (EUR/MWh)",
    legend_title="Market Zones",
    xaxis=dict(
        tickangle=45,
        type="date",  # Ensure the x-axis is treated as dates
    ),
    hovermode="x unified",  # Unified hover for better comparison
    template="plotly_white",  # Clean white background
    width=1000,
    height=600,
)

# Display the interactive plot
fig.show()

Explanation:

  • Plot Details:

    • Lines: Each zone’s clearing price over time is represented by a distinct line.

    • Interactivity: The Plotly plot allows for interactive exploration of the data, such as zooming and hovering for specific values.

    • Aesthetics: The clean white template and clear labels enhance readability.

  • Interpretation:

    • Price Trends: Observing how clearing prices fluctuate over time within each zone.

    • Impact of Transmission Capacity: Comparing price levels between zones can reveal the effects of transmission capacities on market equilibrium. For instance, higher transmission capacity might lead to more price convergence between zones, while zero capacity could result in divergent price levels due to isolated supply and demand dynamics.

Conclusion#

Congratulations! You’ve successfully navigated through the Market Zone Coupling process using the ASSUME Framework. Here’s a quick recap of what you’ve accomplished:

Key Achievements:#

  1. Market Setup:

    • Defined Zones and Buses: Established distinct market zones and configured their connections through transmission lines.

    • Configured Units: Set up power plant and demand units within each zone, detailing their operational parameters.

  2. Market Clearing Optimization:

    • Implemented Optimization Model: Utilized a simplified Pyomo-based model to perform market clearing, accounting for bid acceptances and power flows.

    • Simulated Transmission Scenarios: Ran simulations with varying transmission capacities to observe their impact on energy distribution and pricing.

  3. Result Analysis:

    • Extracted Clearing Prices: Retrieved and interpreted market prices from the optimization results.

    • Visualized Outcomes: Created interactive plots to compare how different transmission capacities influence market dynamics across zones.

Key Takeaways:#

  • Impact of Transmission Capacity: Transmission limits play a crucial role in determining energy flows and price convergence between market zones.

  • ASSUME Framework Efficiency: ASSUME streamlines complex market simulations, making it easier to model and analyze multi-zone interactions.

Next Steps:#

  • Integrate Renewable Sources: Expand the model to include renewable energy units and assess their impact on market dynamics.

  • Scale Up Simulations: Apply the framework to larger, more complex market scenarios to further test its capabilities.

Thank you for participating in this tutorial! With the foundational knowledge gained, you’re now equipped to delve deeper into energy market simulations and leverage the ASSUME framework for more advanced analyses.