Screening Curves¶
Compute the long-term equilibrium power plant investment for a given load duration curve ($1000-1000z$ for $z \in [0,1]$) and a given set of generator investment options.
To read up on the theory behind screenin curves, see this lecture.
import numpy as np
import pandas as pd
import pypsa
Generator marginal (m) and capital (c) costs are given in EUR/MWh - numbers chosen for simple answer.
generators = {
"coal": {"m": 2, "c": 15},
"gas": {"m": 12, "c": 10},
"load-shedding": {"m": 1012, "c": 0},
}
The screening curve intersections are at 0.01 and 0.5.
x = np.linspace(0, 1, 101)
df = pd.DataFrame({k: v["c"] + x * v["m"] for k, v in generators.items()}, index=x)
df.plot(ylim=[0, 50], title="Screening Curve");
n = pypsa.Network()
num_snapshots = 1001
n.snapshots = np.linspace(0, 1, num_snapshots)
n.snapshot_weightings = n.snapshot_weightings / num_snapshots
n.add("Bus", name="bus")
n.add("Load", name="load", bus="bus", p_set=1000 - 1000 * n.snapshots.values)
for gen in generators:
n.add(
"Generator",
name=gen,
bus="bus",
p_nom_extendable=True,
marginal_cost=generators[gen]["m"],
capital_cost=generators[gen]["c"],
)
n.loads_t.p_set.plot.area(title="Load Duration Curve", ylabel="MW")
n.optimize()
n.objective
The capacity is set by total electricity required.
NB: No load shedding since all prices are below 10 000.
n.generators.p_nom_opt.round(2)
n.buses_t.marginal_price.plot(title="Price Duration Curve")
The prices correspond either to VOLL (1012) for first 0.01 or the marginal costs (12 for 0.49 and 2 for 0.5)
Except for (infinitesimally small) points at the screening curve intersections, which correspond to changing the load duration near the intersection, so that capacity changes. This explains 7 = (12+10 - 15) (replacing coal with gas) and 22 = (12+10) (replacing load-shedding with gas).
Note: What remains unclear is what is causing l = 0... it should be 2.
n.buses_t.marginal_price.round(2).sum(axis=1).value_counts()
n.generators_t.p.plot(ylim=[0, 600], title="Generation Dispatch")
Demonstrate zero-profit condition.
- The total cost is given by
weights = n.snapshot_weightings.objective
(
n.generators.p_nom_opt * n.generators.capital_cost
+ weights @ n.generators_t.p * n.generators.marginal_cost
)
- The total revenue by
weights @ n.generators_t.p.mul(n.buses_t.marginal_price["bus"], axis=0)
Now, take the capacities from the above long-term equilibrium, then disallow expansion.
Show that the resulting market prices are identical.
This holds in this example, but does not necessarily hold and breaks down in some circumstances (for example, when there is a lot of storage and inter-temporal shifting).
n.generators.p_nom_extendable = False
n.generators.p_nom = n.generators.p_nom_opt
n.optimize();
n.buses_t.marginal_price.plot(title="Price Duration Curve")
n.buses_t.marginal_price.sum(axis=1).value_counts()
Demonstrate zero-profit condition. Differences are due to singular times, see above, not a problem
- Total costs
(
n.generators.p_nom * n.generators.capital_cost
+ weights @ n.generators_t.p * n.generators.marginal_cost
)
- Total revenue
weights @ n.generators_t.p.mul(n.buses_t.marginal_price["bus"], axis=0)