In PyPSA v0.22
, an additional optimization module was instroduced to the package. It is built on Linopy and aims at
lopf
with pyomo=False
)Linopy is a stand-alone package and works similar to Pyomo but without the memory overhead and much faster. In the long-term we are planning to slowly migrate towards the Linopy-based optimization only. In order to facilitate the transission from the native PyPSA optimization (lopf
with pyomo=False
), the module pypsa.optimization.compat
provides functions similar to pypsa.linopt
. Have a look of our migration guide (next notebook).
If you don't have any code to migrate, we recommend to directly use the linopy functions instead.
For additional information on the Linopy package, have a look at the documentation.
Now, we demonstrate the behaviour of the optimization with linopy. The core functions for the optimization can be called via the Network.optimize
accessor. The accessor is used for creating, solving, modifying the optimization problem. Further it supports to run different optimzation formulations and provides helper functions.
At first, we run the ordinary linearized optimal power flow (LOPF). We then extend the formulation by some additional constraints.
import pypsa
import pandas as pd
n = pypsa.examples.ac_dc_meshed(from_master=True)
In order to make the network a bit more interesting, we modify its data: We set gas generators to non-extendable,
n.generators.loc[n.generators.carrier == "gas", "p_nom_extendable"] = False
... add ramp limits,
n.generators.loc[n.generators.carrier == "gas", "ramp_limit_down"] = 0.2
n.generators.loc[n.generators.carrier == "gas", "ramp_limit_up"] = 0.2
... add additional storage units (cyclic and non-cyclic) and fix one state_of_charge,
n.add(
"StorageUnit",
"su",
bus="Manchester",
marginal_cost=10,
inflow=50,
p_nom_extendable=True,
capital_cost=10,
p_nom=2000,
efficiency_dispatch=0.5,
cyclic_state_of_charge=True,
state_of_charge_initial=1000,
)
n.add(
"StorageUnit",
"su2",
bus="Manchester",
marginal_cost=10,
p_nom_extendable=True,
capital_cost=50,
p_nom=2000,
efficiency_dispatch=0.5,
carrier="gas",
cyclic_state_of_charge=False,
state_of_charge_initial=1000,
)
n.storage_units_t.state_of_charge_set.loc[n.snapshots[7], "su"] = 100
...and add an additional store.
n.add("Bus", "storebus", carrier="hydro", x=-5, y=55)
n.madd(
"Link",
["battery_power", "battery_discharge"],
"",
bus0=["Manchester", "storebus"],
bus1=["storebus", "Manchester"],
p_nom=100,
efficiency=0.9,
p_nom_extendable=True,
p_nom_max=1000,
)
n.madd(
"Store",
["store"],
bus="storebus",
e_nom=2000,
e_nom_extendable=True,
marginal_cost=10,
capital_cost=10,
e_nom_max=5000,
e_initial=100,
e_cyclic=True,
);
Per default the optimization based on linopy mimics the well-known n.lopf
optimization. We run it by calling the optimize
accessor.
n.optimize()
Compared to the native optimization, we now have a model instance attached to our network. It is a container of all variables, constraints and the objective function. You can modify this as much as you please, by directly adding or deleting variables or constraints etc.
n.model
When you have a fresh network and you just want to create the model instance, run
n.optimize.create_model();
Through the model instance we gain a lot of flexibility. Let's say for example we want to remove the Kirchhoff Voltage Law constraint, thus convert the model to a transport model. This can be done via
n.model.constraints.remove("Kirchhoff-Voltage-Law")
Now, we want to optimize the altered model and feed to solution back to the network. Here again, we use the optimize
accessor.
n.optimize.solve_model()
Here we followed the recommended way to run altered models:
n.optimize.create_model()
n.optimize.solve_model()
For compatibility reasons the optimize
function, also allows to pass a extra_funcionality
argument, as we know it from the lopf
function. The above behaviour with use of the extra functionality is obtained through
def remove_kvl(n, sns):
print("KVL removed!")
n.model.constraints.remove("Kirchhoff-Voltage-Law")
n.optimize(extra_functionality=remove_kvl)
In the following we examplarily present a set of additional constraints. Note, the dual values of the additional constraints won't be stored in default data fields in the PyPSA
network. But in any case they are stored in the linopy.Model
.
Again, we first build the optimization model, add our constraints and finally solve the network. For the first step we use again our accessor optimize
to access the function create_model
. This returns the linopy
model that we can modify.
m = n.optimize.create_model() # the return value is the model, let's use it directly!
Assume we want to set a minimum state of charge of 50 MWh in our storage unit. This is done by:
sus = m.variables["StorageUnit-state_of_charge"]
m.add_constraints(sus >= 50, name="StorageUnit-minimum_soc")
The return value of the add_constraints
function is a array with the labels of the constraints. You can access the constraint now through:
m.constraints["StorageUnit-minimum_soc"]
and inspects its attributes like lhs
, sign
and rhs
, e.g.
m.constraints["StorageUnit-minimum_soc"].rhs
The battery in our system is modelled with two links and a store. We should make sure that its charging and discharging capacities, meaning their links, are somehow coupled.
capacity = m.variables["Link-p_nom"]
eff = n.links.at["battery_power", "efficiency"]
lhs = capacity["battery_power"] - eff * capacity["battery_discharge"]
m.add_constraints(lhs == 0, name="Link-battery_fix_ratio")
For this, we use the linopy function groupby_sum
which follows the pattern from pandas
/xarray
's groupby
function.
total_demand = n.loads_t.p_set.sum().sum()
prod_per_bus = m.variables["Generator-p"].groupby_sum(n.generators.bus).sum("snapshot")
m.add_constraints(prod_per_bus >= total_demand / 5, name="Bus-minimum_production_share")
con = prod_per_bus >= total_demand / 5
con.lhs
... and now let's solve the network again.
n.optimize.solve_model()
Let's see if the system got our own constraints. We look at n.constraints
which combines summarises constraints going into the linear problem
n.model.constraints
The last three entries show our constraints. Let's check whether out two custom constraint are fulfilled:
n.links.loc[["battery_power", "battery_discharge"], ["p_nom_opt"]]
n.storage_units_t.state_of_charge
n.generators_t.p.groupby(n.generators.bus, axis=1).sum().sum() / n.loads_t.p.sum().sum()
Looks good! Now, let's see which dual values were parsed. Therefore we have a look into n.model.dual
n.model.dual
n.model.dual["StorageUnit-minimum_soc"]
n.model.dual["Link-battery_fix_ratio"]
n.model.dual["Bus-minimum_production_share"]
These are the basic functionalities of the optimize
accessor. There are many more functions like abstract optimziation formulations (security constraint optimization, iterative transmission expansion optimization, etc.) or helper functions (fixing optimized capacities, adding load shedding). Try them out if you want!
print("\n".join([func for func in n.optimize.__dir__() if not func.startswith("_")]))