Demand and Supply Bids in Electricity Markets¶
This example demonstrates a simple market-clearing problem based on supply and demand bids. The market-clearing mechanism illustrated here corresponds to uniform-price auction designs used in electricity day-ahead and auction-based intraday markets, where supply and demand bids are cleared by welfare maximization subject to network constraints.
First, we model a single, integrated market zone to determine the resulting dispatch and prices. Then, we split the system into two zones and the bids are assigned to either North or South in order to analyze the effects of zonal separation on market outcomes.
We model a single snapshot representing a 1‑hour market interval, so power (MW) and energy (MWh) are numerically equivalent in this example.
| Supply Bids | Demand Bids | ||||
|---|---|---|---|---|---|
| Quantity (MW) | Price (EUR/MWh) | Zone | Quantity (MW) | Price (EUR/MWh) | Zone |
| 120 | 0 | North | 250 | 200 | South |
| 100 | 15 | South | 80 | 90 | North |
| 50 | 20 | North | 20 | 75 | North |
| 60 | 36 | North | 40 | 65 | South |
| 70 | 60 | South | 60 | 24 | South |
| 60 | 150 | North | |||
| 50 | 200 | South |
Configuration¶
import matplotlib.pyplot as plt
import numpy as np
import pypsa
plt.style.use("bmh")
supply_bids = {
"qty": [120, 100, 50, 60, 70, 60, 50],
"price": [0, 15, 20, 36, 60, 150, 200],
}
demand_bids = {
"qty": [250, 80, 20, 40, 60],
"price": [200, 90, 75, 65, 24],
}
Single Market Zone¶
The market price is determined by the intersection of the aggregated supply and demand curves. Supply bids are ordered by increasing marginal cost, while demand bids are ordered by decreasing willingness to pay. The market clears at the price where total supplied energy equals total demanded energy. All accepted supply bids receive the clearing price, and all accepted demand bids pay the same price, while bids with marginal costs or willingness to pay beyond the clearing point are not dispatched.
In our case, the intersection of the supply and demand curves occurs at a price of 60 EUR/MWh, which therefore defines the market clearing (marginal) price.
plt.figure(figsize=(8, 4))
plt.step(
np.cumsum([0] + supply_bids["qty"]),
supply_bids["price"][:1] + supply_bids["price"],
label="Supply",
)
plt.step(
np.cumsum([0] + demand_bids["qty"]),
demand_bids["price"][:1] + demand_bids["price"],
label="Demand",
)
plt.xlabel("Quantity (MW)")
plt.ylabel("Price (EUR/MWh)")
plt.legend()
<matplotlib.legend.Legend at 0x77928b3578c0>
PyPSA Implementation of Single Market Zone¶
The market is modeled in PyPSA using a single bus that represents an integrated market zone.
Supply bids are implemented as generators with positive nominal capacities and marginal costs equal to their bid prices.
Demand bids are modeled as generators with sign = -1, meaning that a positive dispatch corresponds to power consumption rather than injection. The marginal cost is set to the negative of the willingness to pay, so that accepting a demand bid reduces the objective function. This formulation allows supply and demand bids to be treated symmetrically within the cost-minimizing optimization, resulting in a standard market-clearing outcome.
n = pypsa.Network()
n.add("Bus", "single-node")
# Add supply bids
n.add(
"Generator",
name=[f"supply_{i}" for i in range(len(supply_bids["qty"]))],
bus="single-node",
marginal_cost=supply_bids["price"],
p_nom=supply_bids["qty"],
)
# Add demand bids
n.add(
"Generator",
name=[f"demand_{i}" for i in range(len(demand_bids["qty"]))],
bus="single-node",
p_nom=demand_bids["qty"],
marginal_cost=[-p for p in demand_bids["price"]],
sign=-1,
)
n.optimize()
/tmp/ipykernel_3740/1261279110.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['single-node'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 12 primals, 25 duals Objective: -5.30e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
Price Formation Theory and Objective Function¶
Market clearing can be formulated as a welfare maximization problem, where total social welfare is defined as the difference between the willingness to pay of consumers and the production costs of generators.
Accepted demand bids contribute positively to welfare, while accepted supply bids reduce welfare according to their marginal costs. The optimal dispatch is obtained by maximizing this net surplus subject to capacity and balance constraints.
In the following, $p_i$ and $p_j$ denote the dispatched quantities of individual demand and supply bids, respectively. The index $i \in \mathcal{D}$ runs over all demand bids, while $j \in \mathcal{S}$ runs over all supply bids. The parameters $\lambda_i^{\text{demand}}$ represent the willingness to pay of demand bids, and $\lambda_j^{\text{supply}}$ denote the marginal costs of supply bids.
The optimization determines the accepted bid quantities $p$ that maximize total welfare subject to the market and capacity constraints.
$ \max \;\; \sum_{i \in \mathcal{D}} \lambda_i^{\text{demand}} \, p_i \;-\; \sum_{j \in \mathcal{S}} \lambda_j^{\text{supply}} \, p_j$
PyPSA internally solves a cost-minimization problem. Welfare maximization can be written in this form by assigning negative marginal costs to demand bids so that accepting demand increases welfare while still fitting into a minimization framework. This formulation allows supply and demand bids to be treated symmetrically within PyPSA’s standard optimization model.
$\min_{p} \;\; \sum_{j \in \mathcal{S}} \lambda_j^{\text{supply}} \, p_j \;-\; \sum_{i \in \mathcal{D}} \lambda_i^{\text{demand}} \, p_i$
# Show objective function
display(n.model.objective)
Objective: ---------- LinearExpression: +0 Generator-p[now, supply_0] + 15 Generator-p[now, supply_1] + 20 Generator-p[now, supply_2] ... -75 Generator-p[now, demand_2] - 65 Generator-p[now, demand_3] - 24 Generator-p[now, demand_4] Sense: min Value: -53040.0
Power Balance and Generator Constraints¶
The optimization is subject to power balance and bid acceptance constraints. At each bus, total supplied power must equal total consumed power.
In addition, dispatch is limited by the bid quantities, so that only volumes offered in the market can be accepted.
The nodal power balance is given by $$ \sum_{j \in \mathcal{S}} p_j - \sum_{i \in \mathcal{D}} p_i = 0 . $$
Bid acceptance is constrained by the offered quantities $$ 0 \le p_j \le \bar{p}_j \quad \forall j \in \mathcal{S}, $$ $$ 0 \le p_i \le \bar{p}_i \quad \forall i \in \mathcal{D}. $$ Where $\bar{p}_j$ and $\bar{p}_i$ represent the upper bounds/bid quantities offered to the market. The lower bound of zero ensures that dispatched quantities cannot be negative, so bids are either accepted with a non-negative volume or not accepted at all.
# Show nodal power balance constraint
n.model.constraints["Bus-nodal_balance"]
Constraint `Bus-nodal_balance` [name: 1, snapshot: 1]: ------------------------------------------------------ [single-node, now]: +1 Generator-p[now, supply_0] + 1 Generator-p[now, supply_1] + 1 Generator-p[now, supply_2] ... -1 Generator-p[now, demand_2] - 1 Generator-p[now, demand_3] - 1 Generator-p[now, demand_4] = -0.0
# Show maximum and minimum power output constraints for all bids
display(n.model.constraints["Generator-fix-p-upper"])
display(n.model.constraints["Generator-fix-p-lower"])
Constraint `Generator-fix-p-upper` [snapshot: 1, name: 12]: ----------------------------------------------------------- [now, supply_0]: +1 Generator-p[now, supply_0] ≤ 120.0 [now, supply_1]: +1 Generator-p[now, supply_1] ≤ 100.0 [now, supply_2]: +1 Generator-p[now, supply_2] ≤ 50.0 [now, supply_3]: +1 Generator-p[now, supply_3] ≤ 60.0 [now, supply_4]: +1 Generator-p[now, supply_4] ≤ 70.0 [now, supply_5]: +1 Generator-p[now, supply_5] ≤ 60.0 [now, supply_6]: +1 Generator-p[now, supply_6] ≤ 50.0 [now, demand_0]: +1 Generator-p[now, demand_0] ≤ 250.0 [now, demand_1]: +1 Generator-p[now, demand_1] ≤ 80.0 [now, demand_2]: +1 Generator-p[now, demand_2] ≤ 20.0 [now, demand_3]: +1 Generator-p[now, demand_3] ≤ 40.0 [now, demand_4]: +1 Generator-p[now, demand_4] ≤ 60.0
Constraint `Generator-fix-p-lower` [snapshot: 1, name: 12]: ----------------------------------------------------------- [now, supply_0]: +1 Generator-p[now, supply_0] ≥ -0.0 [now, supply_1]: +1 Generator-p[now, supply_1] ≥ -0.0 [now, supply_2]: +1 Generator-p[now, supply_2] ≥ -0.0 [now, supply_3]: +1 Generator-p[now, supply_3] ≥ -0.0 [now, supply_4]: +1 Generator-p[now, supply_4] ≥ -0.0 [now, supply_5]: +1 Generator-p[now, supply_5] ≥ -0.0 [now, supply_6]: +1 Generator-p[now, supply_6] ≥ -0.0 [now, demand_0]: +1 Generator-p[now, demand_0] ≥ -0.0 [now, demand_1]: +1 Generator-p[now, demand_1] ≥ -0.0 [now, demand_2]: +1 Generator-p[now, demand_2] ≥ -0.0 [now, demand_3]: +1 Generator-p[now, demand_3] ≥ -0.0 [now, demand_4]: +1 Generator-p[now, demand_4] ≥ -0.0
Single Market Zone Marginal Price¶
The market price obtained from the PyPSA optimization matches the marginal price identified in the supply and demand curve plot. This confirms that the numerical market-clearing solution implemented in PyPSA is consistent with the graphical intersection of aggregated supply and demand.
n.buses_t.marginal_price
| name | single-node |
|---|---|
| snapshot | |
| now | 60.0 |
The dispatch results show which bids are accepted in the market. Bids with the lowest marginal costs or highest willingness to pay on the demand side (like supply_0 and demand_0) are dispatched first, while more expensive bids are only used if required to balance supply and demand.
n.generators_t.p
| name | supply_0 | supply_1 | supply_2 | supply_3 | supply_4 | supply_5 | supply_6 | demand_0 | demand_1 | demand_2 | demand_3 | demand_4 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| snapshot | ||||||||||||
| now | 120.0 | 100.0 | 50.0 | 60.0 | 60.0 | -0.0 | -0.0 | 250.0 | 80.0 | 20.0 | 40.0 | -0.0 |
Two-Zone Market Configuration¶
The market is now split into two zones. We assign each bid to a bidding zone and clear the market under zonal constraints rather than as a single aggregated node. This allows the subsequent optimization to represent zonal market outcomes, and potentially different prices between the zones.
supply_bids["zone"] = ["north", "south", "north", "north", "south", "north", "south"]
demand_bids["zone"] = ["south", "north", "north", "south", "south"]
Two-Zone Market without Interconnection¶
With no interconnection between the two zones, each zone clears independently based on its local supply and demand bids. In this case, the North zone has sufficient low-cost supply to meet demand, resulting in a marginal price of zero, while the South zone relies on expensive supply to balance demand, leading to a high marginal price of 200 EUR/MWh. The large price difference reflects the absence of cross-zonal trading and illustrates how transmission constraints can isolate markets and amplify regional price disparities.
plt.figure(figsize=(8, 4))
for zone, linestyle in zip(["north", "south"], ["-", "--"]):
s_idx = [i for i, z in enumerate(supply_bids["zone"]) if z == zone]
d_idx = [i for i, z in enumerate(demand_bids["zone"]) if z == zone]
s_qty = [supply_bids["qty"][i] for i in s_idx]
s_price = [supply_bids["price"][i] for i in s_idx]
d_qty = [demand_bids["qty"][i] for i in d_idx]
d_price = [demand_bids["price"][i] for i in d_idx]
plt.step(
np.cumsum([0] + s_qty),
[s_price[0]] + s_price,
label=f"Supply ({zone})",
color="C0",
linestyle=linestyle,
)
plt.step(
np.cumsum([0] + d_qty),
[d_price[0]] + d_price,
label=f"Demand ({zone})",
color="C1",
linestyle=linestyle,
)
plt.xlabel("Quantity (MW)")
plt.ylabel("Price (EUR/MWh)")
plt.legend()
plt.tight_layout()
The PyPSA optimization reproduces the same outcome as the graphical analysis.
n2 = pypsa.Network()
n2.add("Bus", "north")
n2.add("Bus", "south")
n2.add(
"Generator",
name=[f"supply_{i}" for i in range(len(supply_bids["qty"]))],
bus=supply_bids["zone"],
marginal_cost=supply_bids["price"],
p_nom=supply_bids["qty"],
)
n2.add(
"Generator",
name=[f"demand_{i}" for i in range(len(demand_bids["qty"]))],
bus=demand_bids["zone"],
p_nom=demand_bids["qty"],
marginal_cost=[-p for p in demand_bids["price"]],
sign=-1,
)
n2.optimize()
/tmp/ipykernel_3740/1256367358.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n2.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['north', 'south'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.01s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 12 primals, 26 duals Objective: -3.70e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
n2.buses_t.marginal_price
| name | north | south |
|---|---|---|
| snapshot | ||
| now | -0.0 | 200.0 |
We compare the welfare outcome of the single integrated market model (n) with that of the two-zone market model (n2). Splitting the market into separate zones results in a lower total welfare, as reflected by a less negative objective value in n2. This welfare reduction arises because zonal separation restricts mutually beneficial trades that are possible in the single-market (copperplate) case.
display(n.objective)
display(n2.objective)
-53040.0
-37000.0
Two-Zone Market with Interconnection Line¶
To relax the zonal separation, an interconnection line with a capacity of 100 MW is added between the zones. This line allows power to be transferred between the two markets, enabling partial coupling of supply and demand across zones.
As a result, price differences and welfare losses caused by complete market separation are reduced, but still limited by the transfer capacity.
n2.add("Line", "line", bus0="north", bus1="south", s_nom=100)
n2.optimize()
/tmp/ipykernel_3740/3524542017.py:2: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n2.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['north', 'south'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['line'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero x, which could break the linear load flow: Index(['line'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow: Index(['line'], dtype='object', name='name')
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['0', '1'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 13 primals, 28 duals Objective: -5.22e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Line-fix-s-lower, Line-fix-s-upper were not assigned to the network.
('ok', 'optimal')
display(n2.objective)
display(n2.buses_t.marginal_price)
-52220.0
| name | north | south |
|---|---|---|
| snapshot | ||
| now | 36.0 | 65.0 |