Security-Constrained LOPF¶
In this example, the dispatch of generators is optimised using the security-constrained linear optimal power flow (SCLOPF) functionality of PyPSA, to guarantee that no branches are overloaded in the event of certain branch outages.
import pypsa
n = pypsa.examples.scigrid_de()
# correct some infeasibilties in the network
for line_name in ["316", "527", "602"]:
n.lines.loc[line_name, "s_nom"] = 1200
now = n.snapshots[0]
n.plot(bus_size=0);
First, let's run the network without any $N-1$ security constraints and see how much it costs to operate the system.:
n0 = n.copy()
n0.optimize(snapshots=now)
n0.statistics.opex().sum()
The security-constrained linear optimal power flow (SCLOPF) is executed using the n.optimize.optimize_security_constrained() method.
This method takes a list of snapshots (here: now) and a list of branches that are to be considered as outages (here, the 30 lines with the highest loading).
branch_outages = (n0.lines_t.p0.loc[now] / n0.lines.s_nom).nlargest(30).index
n.optimize.optimize_security_constrained(
now, branch_outages=branch_outages, log_to_console=False
)
n.statistics.opex().sum()
You can see that the cost of operating the system in the given hour rises from 332 k€ to 427 k€ when the security constraints are applied.
The maps below indicate the difference in the dispatch patterns, line flows and marginal prices between the $N-0$ (first) and $N-1$ (second) cases.
def plot_network(n, snapshot):
bus_size = (
n.statistics.supply(groupby="bus", components=["Generator", "StorageUnit"])
.groupby("bus")
.sum()
)
line_flows = n.lines_t.p0.loc[snapshot]
bus_color = n.buses_t.marginal_price.loc[snapshot]
line_loading = n.lines_t.p0.abs().loc[snapshot] / n.lines.s_nom
n.plot(
bus_size=bus_size / 30000,
bus_color=bus_color,
bus_cmap="Reds",
line_color=line_loading,
line_flow=line_flows / 50,
)
plot_network(n0, now)
plot_network(n, now)
We can also look at where the nodal dispatch is ramped up (red) or down (blue) in the $N-1$ case compared to the $N-0$ case. Mostly this means ramp down upstream of the potential outages, and ramp up downstream of the potential outages.
bus_size0 = (
(
n0.statistics.supply(groupby="bus", components=["Generator", "StorageUnit"])
.groupby("bus")
.sum()
)
.reindex(index=n.buses.index)
.fillna(0)
)
bus_size1 = (
(
n.statistics.supply(groupby="bus", components=["Generator", "StorageUnit"])
.groupby("bus")
.sum()
)
.reindex(index=n.buses.index)
.fillna(0)
)
bus_size = bus_size1 - bus_size0
n.plot(
bus_size=bus_size.abs() / 30000,
bus_color=bus_size.map(lambda x: "b" if x < 0 else "r"),
);
We can also double-check that the $N-1$ constraints are satisfied and no lines are overloaded in the event of the outages considered:
n.optimize.fix_optimal_dispatch()
p0_test = n.lpf_contingency(now, branch_outages=branch_outages)
Check the maximum loading as per unit of s_nom in each contingency:
max_loading = abs(p0_test.divide(n.passive_branches().s_nom, axis=0)).max()
max_loading