Imperfect Competition¶
This example demonstrates how to model oligopolistic behaviour in energy markets using PyPSA. The notebook provides some theoretical background and a quick neat PyPSA implementation.
Spoiler: we implemented Cournot-Nash equilibrium without KKT conditions, complementarity solvers, big-M constraints, or any structural changes to cost-minimization problem instance created by PyPSA. Instead, we use a trick known as the "fictitious objective" in the economics/OR literature, perhaps underappreciated in energy system modelling stream.
Background: why bother?¶
We can think of plentiful examples of oligopolistic markets in energy context, where handful of large producers can exercise market power and drive up prices: natural gas supply, gas turbines manufacturing, material supply chains, etc.
The standard approach to modelling imperfect competition games is via Karush-Kuhn-Tucker (KKT) conditions, formulating Mixed Complementarity Problems (MCPs) and solving them with specialised solvers (mainly PATH). An amazing resource for learning about this is Complementarity Modeling in Energy Markets by Steven Gabriel and co-authors.
MCP feature complementarity constraints that yield non-convexities, but a Big-M method can make them tractable for MIP solvers. Thus we can implement imperfect competition models with complementarity constraints in linopy, for example this quick linopy example covers perfect competition, Cournot-Nash oligopoly, asymmetric information, Stackelberg, and conjectural variations games solved as mixed-integer linear problems based on KKT conditions.
However, this approach is impractical for PyPSA implementation. PyPSA acts as an optimisation wrapper around a network (model) instance — it automatically builds a cost-minimisation problem (LP, MIP, or QP) from the user-defined components and their attributes. Casting an MCP would require automatic derivation of first-order conditions and mapping of dual variables for each component, which would be tricky (if possible at all?) to generalise across all of PyPSA's functionality. It turns out there is another route in the economics/OR literature from 1990s.
The "fictitious objective"¶
The Cournot equilibrium among $N$ players can be characterised as the solution to a single optimisation problem. Margaret Slade's 1994 paper calls it a "fictitious-objective function" that reproduces equilibrium game results. The paper shows that for the homogeneous Cournot game, linear demand is sufficient for the existence of such a fictitious objective.
Ben Hobbs also explored this topic in 2001 paper by studying properties of MCP and LCP (Linear Complementarity Problem) formulations. The paper concludes: "The key assumption that permits their [KKT-based equilibrium] formulation as LCPs is that each producer naively assumes that its output will not affect transmission prices" - so another condition for the existence of a fictitious objective is that there is no binding network constraints that create asymmetric shadow prices across strategic players.
Let's work through the math, then see it in action in PyPSA.
Setup¶
Consider a market with $N$ generators indexed by $i = 1, \ldots, N$, each choosing dispatch $q_i \geq 0$ [MW]. Total supply is $Q = \sum_{i=1}^{N} q_i$.
The market price $P$ is given by the inverse demand function, linear in total supply $Q$:
$$P(Q) = a - b \cdot Q, \qquad a > 0, \; b > 0$$
where $a$ [EUR/MWh] is the maximum willingness to pay and $b$ [EUR/MWh per MW] is the demand slope. In the code example below: $a = 30$, $b = 4$.
Generator cost functions are convex and producer-specific:
$$C_i(q_i), \qquad C_i'(q_i) > 0, \quad C_i''(q_i) \geq 0$$
In the code example, both gas producers use $C_i(q_i) = q_i + 0.5 \cdot q_i^2$, set via marginal_cost=1 and marginal_cost_quadratic=0.5 for simplicity matter. However, the results below hold for any convex cost functions (i.e. no symmetry is required) and any number of generators.
Perfect competition (cost optimisation problem)¶
In standard energy system optimisation (e.g. PyPSA's n.optimize()), the model dispatches generators to minimise total system cost (equivalently, maximise social welfare). Each generator is a price-taker: it bids its marginal cost and the market clears. The generator's implicit problem is:
$$\max_{q_i} \; P \cdot q_i - C_i(q_i)$$
First-Order Condition (FOC):
$$P = C_i'(q_i) \tag{1}$$
Each generator produces up to the point where the market price equals its marginal cost. This is often referred to as the merit-order dispatch (by energy folks), or as a result implied by KKT condition and complementarity slackness (by math folks). The market price (shadow price of the bus power balance constraint) clears the market:
$$P^* = a - b \sum_{i=1}^{N} q_i^*$$
This competitive equilibrium is also the solution to the social welfare maximisation problem:
$$\max_{\{q_i\}} \; \underbrace{\int_0^Q P(s) \, ds}_{\text{gross consumer surplus}} - \sum_{i=1}^{N} C_i(q_i) \tag{W}$$
For linear demand, this becomes:
$$\max_{\{q_i\}} \; a \cdot Q - \frac{b}{2} \cdot Q^2 - \sum_{i=1}^{N} C_i(q_i)$$
Differentiating $(W)$ with respect to $q_i$ recovers exactly the competitive FOC $(1)$:
$$\frac{\partial W}{\partial q_i} = a - b \cdot Q - C_i'(q_i) = P(Q) - C_i'(q_i) = 0$$
This is the First Welfare Theorem: least-cost dispatch produces the competitive equilibrium. This is "Problem A" in the code below, solved by PyPSA's n.optimize() without any modifications.
Cournot competition (strategic generators)¶
The Cournot conjecture¶
Under Cournot competition, each generator recognises that its own dispatch $q_i$ affects the market price. Generator $i$ solves:
$$\max_{q_i} \; P(Q) \cdot q_i - C_i(q_i)$$
where $Q = q_i + Q_{-i}$ and $Q_{-i} = \sum_{j \neq i} q_j$ is the total output of all rivals. The Cournot assumption is that each generator takes rivals' output $Q_{-i}$ as fixed when choosing $q_i$, so $\frac{\partial Q}{\partial q_i} = 1$.
Here, FOC becomes:
$$\frac{\partial}{\partial q_i} \big[ P(Q) \cdot q_i - C_i(q_i) \big] = P(Q) + P'(Q) \cdot q_i - C_i'(q_i) = 0$$
For linear demand, $P'(Q) = -b$, so:
$$P(Q) - b \cdot q_i = C_i'(q_i) \tag{2}$$
Comparing with the competitive FOC $(1)$:
$$\underbrace{P(Q)}_{\text{price-taking}} \quad \longrightarrow \quad \underbrace{P(Q) - b \cdot q_i}_{\text{Cournot}}$$
The term $b \cdot q_i$ is the Cournot markup: the reduction in marginal revenue below price, caused by the price-depressing effect of the generator's own output. A larger generator (higher $q_i$) or a steeper demand curve (higher $b$) produces a bigger markup — and more incentive to withhold output.
Generalisation via conjectural variations (CV)¶
The Cournot model is a special case of conjectural variations. Define generator $i$'s CV parameter $cv_i$ as:
$$cv_i = 1 + \frac{\partial Q_{-i}}{\partial q_i}$$
This captures the generator's belief about how total market output responds to its own dispatch change. When $cv_i = 0$, the generator is a price-taker — it believes its output has no effect on the price, recovering the standard competitive dispatch. For $0 < cv_i < 1$, the generator has partial strategic awareness. At $cv_i = 1$, we get the classic Cournot-Nash assumption — rivals hold output fixed.
The generalised FOC becomes:
$$P(Q) - b \cdot cv_i \cdot q_i = C_i'(q_i) \tag{3}$$
When $cv_i = 0$, this reduces to the competitive FOC $(1)$. When $cv_i = 1$, it is the standard Cournot FOC $(2)$. The linopy example linked above implements this as well, showing how varying $cv_i$ from 0 to 1 traces a continuum of equilibria between perfect competition and Cournot.
From FOC to fictitious objective function¶
Derivation¶
The competitive FOC is:
$$P(Q) - C_i'(q_i) = 0$$
The Cournot FOC is:
$$P(Q) - b \cdot cv_i \cdot q_i - C_i'(q_i) = 0$$
The gap between them is the term $b \cdot cv_i \cdot q_i$. We need a function $M_i(q_i)$ to subtract from the welfare objective such that:
$$\frac{\partial M_i}{\partial q_i} = b \cdot cv_i \cdot q_i$$
Integrating:
$$\boxed{M_i(q_i) = \frac{b \cdot cv_i}{2} \cdot q_i^2} \tag{4}$$
This term can be called the Cournot markup -- a quadratic penalty on each strategic generator's dispatch.
The modified optimisation problem¶
Adding the markup to the welfare objective:
$$\max_{\{q_i\}} \; \underbrace{a \cdot Q - \frac{b}{2} \cdot Q^2 - \sum_{i=1}^{N} C_i(q_i)}_{\text{standard least-cost dispatch}} - \underbrace{\sum_{i=1}^{N} \frac{b \cdot cv_i}{2} \cdot q_i^2}_{\text{Cournot markup}} \tag{W'}$$
So if we differentiate $(W')$ with respect to $q_i$:
$$\frac{\partial W'}{\partial q_i} = \underbrace{a - b \cdot Q}_{= P(Q)} - C_i'(q_i) - b \cdot cv_i \cdot q_i = 0$$
which is exactly the Cournot FOC $(3)$.
This is "Problem B" in the code below, where the markup is appended to the PyPSA model's objective after create_model().
Equivalent minimisation form (cost perspective)¶
Negating, the cost-minimisation form is:
$$\min_{\{q_i\}} \; \sum_{i=1}^{N} C_i(q_i) + \sum_{i=1}^{N} \frac{b \cdot cv_i}{2} \cdot q_i^2 - a \cdot Q + \frac{b}{2} \cdot Q^2$$
The markup acts as an additional quadratic cost on each strategic generator. It raises the effective marginal cost from the true production cost to a "strategic" cost:
$$MC_i^{\text{eff}}(q_i) = \underbrace{C_i'(q_i)}_{\text{true MC}} + \underbrace{b \cdot cv_i \cdot q_i}_{\text{strategic withholding}}$$
In the code example, with $C_i'(q) = 1 + q$, $b = 4$, $cv = 1$:
$$MC_i^{\text{eff}}(q_i) = (1 + q_i) + 4 \cdot q_i = 1 + 5 \cdot q_i$$
The effective marginal cost curve is five times steeper, causing the generator to produce less and the market price to rise.
PyPSA example: two gas producers, one electricity market¶
Now let's see this in action. We build a two-bus network where oligopolistic gas producers drive up electricity costs.
import pandas as pd
# Cost parameters
DEMAND_INTERCEPT = 30 # a in P(d) = a - b*d [EUR/MWh]
DEMAND_SLOPE = 4 # b in P(d) = a - b*d [EUR/MWh per MW]
LINEAR_COST = 1 # linear cost coefficient [EUR/MWh]
QUADRATIC_COST = 0.5 # quadratic cost coefficient [EUR/MWh per MW]
# Capacities and demands
SOLAR_CAPACITY = 3.0 # [MW] installed solar capacity
SOLAR_CF = [0.8, 0.5, 0.1] # capacity factors per snapshot
GAS_PLANT_CAPACITY = 8.0 # [MW] gas backup plant capacity
GAS_PRODUCER_CAPACITY = 5.0 # [MW] each gas producer's capacity
DEMAND_MAX = 10.0 # [MW] maximum demand quantity
def create_network():
"""Create the two-bus electricity + gas network.
The network has 3 snapshots with decreasing solar output,
creating increasing residual demand for gas backup.
"""
n = pypsa.Network()
n.set_snapshots(range(3))
n.add("Bus", "electricity")
n.add("Bus", "gas")
# --- Electricity market ---
# Solar: zero-cost renewable with time-varying capacity factor
n.add(
"Generator",
"solar",
bus="electricity",
p_nom=SOLAR_CAPACITY,
marginal_cost=0,
p_max_pu=SOLAR_CF,
)
# Elastic demand: inverse demand curve P(d) = 30 - 4*d
# Modelled as a sign=-1 generator ("demand bid"):
# Objective contribution: (-30)*d + 2*d^2
# FOC: -30 + 4*d = -lambda (lambda = shadow price on bus)
# Hence: lambda = 30 - 4*d = P(d)
n.add(
"Generator",
"elastic_demand",
bus="electricity",
sign=-1,
p_nom=DEMAND_MAX,
marginal_cost=-DEMAND_INTERCEPT,
marginal_cost_quadratic=DEMAND_SLOPE / 2,
)
# --- Gas market ---
# Two gas producers with identical convex cost C(q) = q + 0.5*q^2
for i in [1, 2]:
n.add(
"Generator",
f"gas_producer_{i}",
bus="gas",
p_nom=GAS_PRODUCER_CAPACITY,
marginal_cost=LINEAR_COST,
marginal_cost_quadratic=QUADRATIC_COST,
)
# --- Gas-to-electricity link (gas backup plant) ---
# Converts gas to electricity 1:1 for simplicity.
# The gas bus shadow price becomes the fuel cost of gas-fired power.
n.add(
"Link",
"gas_plant",
bus0="gas",
bus1="electricity",
p_nom=GAS_PLANT_CAPACITY,
marginal_cost=0,
efficiency=1.0,
)
return n
# Create the network
n = create_network()
Problem A — Perfect competition ($cv = 0$)¶
Note that under convexity & price-taking assumptions, cost minimisation yields the competitive equilibrium (and thus a social welfare maximisation).
n_a = create_network()
m_a = n_a.optimize.create_model()
# investigate the model structure
print(m_a)
WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['electricity', 'gas'], dtype='object', name='name')
WARNING:pypsa.consistency:The following links have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['gas_plant'], dtype='object', name='name')
/tmp/ipykernel_4956/1751098940.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. m_a = n_a.optimize.create_model()
Linopy QP model =============== Variables: ---------- * Generator-p (snapshot, name) * Link-p (snapshot, name) Constraints: ------------ * Generator-fix-p-lower (snapshot, name) * Generator-fix-p-upper (snapshot, name) * Link-fix-p-lower (snapshot, name) * Link-fix-p-upper (snapshot, name) * Bus-nodal_balance (name, snapshot) Status: ------- initialized
# Problem A is the PyPSA unmodified cost min model
status_a, cond_a = n_a.optimize.solve_model(solver_name="highs")
print(f"\n{'=' * 60}")
print(" Problem A: Perfect Competition (cv = 0)")
print(f"{'=' * 60}")
print(f" Status: {status_a} | {cond_a}")
print(f" Objective: {n_a.objective:.2f}")
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.04s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 15 primals, 36 duals Objective: -2.96e+02 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
============================================================ Problem A: Perfect Competition (cv = 0) ============================================================ Status: ok | optimal Objective: -296.27
Problem B — Cournot-Nash competition ($cv = 1$)¶
Each gas producer $i$ internalises its impact on the market price. From the Cournot FOC:
$$-P + b \cdot cv \cdot q_i + MC(q_i) = 0 \quad \Rightarrow \quad MC^{\text{eff}} = (1 + q_i) + 4 \cdot q_i = 1 + 5 \cdot q_i$$
To obtain this FOC from a minimisation problem, we add the markup term $(b \cdot cv / 2) \cdot q_i^2$ to each producer's objective in the linopy model (here: PyPSA objective function).
n_b = create_network()
m_b = n_b.optimize.create_model()
# Access gas producer dispatch variables from the linopy model
q1 = m_b["Generator-p"].sel(name="gas_producer_1")
q2 = m_b["Generator-p"].sel(name="gas_producer_2")
# Cournot game parameters
cv = 1 # conjectural variation: 1 = Cournot (best-response)
b = DEMAND_SLOPE # inverse demand slope = 4
# Cournot markup: (b*cv/2) * q_i^2 for each producer, each snapshot
# This effectively raises each producer's marginal cost from (1+q) to (1+5q)
cournot_markup = (b * cv / 2) * (q1 * q1 + q2 * q2)
# Append markup to the PyPSA model's objective
m_b.objective = m_b.objective.expression + cournot_markup.sum()
# Solve
status_b, cond_b = n_b.optimize.solve_model(solver_name="highs")
print(f"\n{'=' * 60}")
print(" Problem B: Cournot-Nash Competition (cv = 1)")
print(f"{'=' * 60}")
print(f" Status: {status_b} | {cond_b}")
print(f" Objective: {n_b.objective:.2f}")
WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['electricity', 'gas'], dtype='object', name='name')
WARNING:pypsa.consistency:The following links have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['gas_plant'], dtype='object', name='name')
/tmp/ipykernel_4956/3672496199.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. m_b = n_b.optimize.create_model()
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 15 primals, 36 duals Objective: -2.39e+02 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
============================================================ Problem B: Cournot-Nash Competition (cv = 1) ============================================================ Status: ok | optimal Objective: -238.89
Economic analysis & welfare decomposition¶
Now the interesting part — how does market power affect welfare? We decompose the consumer surplus loss into a revenue transfer to producers and deadweight welfare loss.
First, let's collect results:
def production_cost(q):
"""True production cost: C(q) = q + 0.5*q^2"""
return LINEAR_COST * q + QUADRATIC_COST * q**2
def analyse_economics(n, label):
"""Compute economic metrics for a solved network."""
q1 = n.generators_t.p["gas_producer_1"].values
q2 = n.generators_t.p["gas_producer_2"].values
p_gas = n.buses_t.marginal_price["gas"].values
p_elec = n.buses_t.marginal_price["electricity"].values
demand = n.generators_t.p["elastic_demand"].values
# Production costs
cost1 = production_cost(q1)
cost2 = production_cost(q2)
# Revenue = price * quantity
rev1 = p_gas * q1
rev2 = p_gas * q2
# Profit = revenue - cost
profit1 = rev1 - cost1
profit2 = rev2 - cost2
# Consumer expenditure (what electricity consumers pay)
consumer_exp = p_elec * demand
# Consumer surplus: integral under demand curve minus expenditure
# integral_0^d P(x)dx = a*d - (b/2)*d^2
consumer_surplus = (
DEMAND_INTERCEPT * demand - (DEMAND_SLOPE / 2) * demand**2 - consumer_exp
)
return {
"label": label,
"q1": q1,
"q2": q2,
"demand": demand,
"p_gas": p_gas,
"p_elec": p_elec,
"cost1": cost1,
"cost2": cost2,
"profit1": profit1,
"profit2": profit2,
"total_producer_profit": (profit1 + profit2).sum(),
"total_production_cost": (cost1 + cost2).sum(),
"total_consumer_expenditure": consumer_exp.sum(),
"total_consumer_surplus": consumer_surplus.sum(),
}
econ_a = analyse_economics(n_a, "A: Competitive")
econ_b = analyse_economics(n_b, "B: Cournot")
print(f"{'=' * 70}")
print(" ECONOMIC ANALYSIS (Totals)")
print(f"{'=' * 70}")
for econ in [econ_a, econ_b]:
print(f"\n --- {econ['label']} ---")
print(f" Total gas production cost: {econ['total_production_cost']:.2f}")
print(f" Total producer profit: {econ['total_producer_profit']:.2f}")
print(f" Total consumer expenditure: {econ['total_consumer_expenditure']:.2f}")
print(f" Total consumer surplus: {econ['total_consumer_surplus']:.2f}")
======================================================================
ECONOMIC ANALYSIS (Totals)
======================================================================
--- A: Competitive ---
Total gas production cost: 36.32
Total producer profit: 20.72
Total consumer expenditure: 71.17
Total consumer surplus: 261.41
--- B: Cournot ---
Total gas production cost: 20.73
Total producer profit: 89.37
Total consumer expenditure: 148.69
Total consumer surplus: 150.66
# --- Decomposition of welfare loss ---
# Welfare = Consumer surplus + Producer surplus (profit)
welfare_a = econ_a["total_consumer_surplus"] + econ_a["total_producer_profit"]
welfare_b = econ_b["total_consumer_surplus"] + econ_b["total_producer_profit"]
welfare_loss = welfare_a - welfare_b # positive = welfare decreased
revenue_increase = econ_b["total_producer_profit"] - econ_a["total_producer_profit"]
consumer_loss = econ_a["total_consumer_surplus"] - econ_b["total_consumer_surplus"]
deadweight_loss = welfare_loss
# Cost increase to electricity system = consumer expenditure increase
cost_increase = (
econ_b["total_consumer_expenditure"] - econ_a["total_consumer_expenditure"]
)
print(f"\n Competitive total welfare: {welfare_a:.2f}")
print(f" Cournot total welfare: {welfare_b:.2f}")
print(f" Welfare loss (DWL): {deadweight_loss:.2f}")
print(f"\n Producer profit (competitive): {econ_a['total_producer_profit']:.2f}")
print(f" Producer profit (Cournot): {econ_b['total_producer_profit']:.2f}")
print(f" Oligopolist revenue increase: {revenue_increase:.2f}")
print(f"\n Consumer surplus (competitive): {econ_a['total_consumer_surplus']:.2f}")
print(f" Consumer surplus (Cournot): {econ_b['total_consumer_surplus']:.2f}")
print(f" Consumer surplus loss: {consumer_loss:.2f}")
print(f"\n Consumer expenditure increase: {cost_increase:.2f}")
print("\n Decomposition of consumer surplus loss:")
print(f" = Revenue transfer to producers: {revenue_increase:.2f}")
print(f" + Deadweight loss: {deadweight_loss:.2f}")
print(f" = Total consumer loss: {revenue_increase + deadweight_loss:.2f}")
print(f" (actual consumer loss: {consumer_loss:.2f})")
Competitive total welfare: 282.13
Cournot total welfare: 240.03
Welfare loss (DWL): 42.10
Producer profit (competitive): 20.72
Producer profit (Cournot): 89.37
Oligopolist revenue increase: 68.65
Consumer surplus (competitive): 261.41
Consumer surplus (Cournot): 150.66
Consumer surplus loss: 110.76
Consumer expenditure increase: 77.52
Decomposition of consumer surplus loss:
= Revenue transfer to producers: 68.65
+ Deadweight loss: 42.10
= Total consumer loss: 110.76
(actual consumer loss: 110.76)
# --- Side-by-side summary table ---
summary = pd.DataFrame(
{
"A: Competitive": {
"Gas price (avg)": econ_a["p_gas"].mean(),
"Elec price (avg)": econ_a["p_elec"].mean(),
"Gas output (total)": (econ_a["q1"] + econ_a["q2"]).sum(),
"Demand (total)": econ_a["demand"].sum(),
"Production cost": econ_a["total_production_cost"],
"Consumer expenditure": econ_a["total_consumer_expenditure"],
"Producer profit": econ_a["total_producer_profit"],
"Consumer surplus": econ_a["total_consumer_surplus"],
"Total welfare": welfare_a,
},
"B: Cournot": {
"Gas price (avg)": econ_b["p_gas"].mean(),
"Elec price (avg)": econ_b["p_elec"].mean(),
"Gas output (total)": (econ_b["q1"] + econ_b["q2"]).sum(),
"Demand (total)": econ_b["demand"].sum(),
"Production cost": econ_b["total_production_cost"],
"Consumer expenditure": econ_b["total_consumer_expenditure"],
"Producer profit": econ_b["total_producer_profit"],
"Consumer surplus": econ_b["total_consumer_surplus"],
"Total welfare": welfare_b,
},
}
)
summary["Delta (B-A)"] = summary["B: Cournot"] - summary["A: Competitive"]
summary.style.format("{:.2f}")
| A: Competitive | B: Cournot | Delta (B-A) | |
|---|---|---|---|
| Gas price (avg) | 3.60 | 10.00 | 6.40 |
| Elec price (avg) | 3.60 | 10.00 | 6.40 |
| Gas output (total) | 15.60 | 10.80 | -4.80 |
| Demand (total) | 19.80 | 15.00 | -4.80 |
| Production cost | 36.32 | 20.73 | -15.59 |
| Consumer expenditure | 71.17 | 148.69 | 77.52 |
| Producer profit | 20.72 | 89.37 | 68.65 |
| Consumer surplus | 261.41 | 150.66 | -110.76 |
| Total welfare | 282.13 | 240.03 | -42.10 |
A note on calibrating demand parameters¶
In energy system models, demand is often modelled as an inelastic load (in PyPSA: Load component with p_set). NB
If demand is perfectly inelastic ($b \to \infty$, $Q = \bar{Q}$ for all prices), the Cournot markup $b \cdot cv_i \cdot q_i \to \infty$. This is economically correct: without any demand response, a generator at margin can charge any price. NB this phenomenon is a reason for various modelling artifacts around price formation that is illustrated in this paper.
The fictitious objective approach to modelling imperfect competition relies on the markup term, which depends on the demand slope $b$. Recall that Slade's 1994 paper proves the existence of a fictitious objective for linear demand.
In practice, demand has some finite price elasticity $\varepsilon < 0$. The point elasticity of linear demand at an observed operating point $(\bar{Q}, \bar{P})$ is:
$$\varepsilon = -\frac{1}{b} \cdot \frac{\bar{P}}{\bar{Q}}$$
Solving for $b$ and $a$:
$$b = \frac{\bar{P}}{\bar{Q} \cdot |\varepsilon|} \tag{5}$$
$$a = \bar{P} \left(1 + \frac{1}{|\varepsilon|}\right) \tag{6}$$
These are used as marginal_cost = -a and marginal_cost_quadratic = b / 2 on an elastic demand generator (with sign=-1), replacing the fixed Load.
Example. At $\bar{P} = 50$ EUR/MWh, $\bar{Q} = 5$ MW, $\varepsilon = -0.1$:
$$b = \frac{50}{5 \times 0.1} = 100, \qquad a = 50 \times (1 + 10) = 550$$
PS There are various functional forms for demand elasticity, which is also illustrated in the Price formation without fuel costs (..) paper.