#!/usr/bin/env python # coding: utf-8 # # pandapower Optimal Power Flow # This is an introduction into the usage of the pandapower optimal power flow. It shows how to set the constraints and the cost factors into the pandapower element tables. # # ## Example Network # # We use the following four bus example network for this tutorial: # # # # We first create this network in pandapower: # In[1]: import pandapower as pp import numpy as np net = pp.create_empty_network() #create buses bus1 = pp.create_bus(net, vn_kv=220.) bus2 = pp.create_bus(net, vn_kv=110.) bus3 = pp.create_bus(net, vn_kv=110.) bus4 = pp.create_bus(net, vn_kv=110.) #create 220/110 kV transformer pp.create_transformer(net, bus1, bus2, std_type="100 MVA 220/110 kV") #create 110 kV lines pp.create_line(net, bus2, bus3, length_km=70., std_type='149-AL1/24-ST1A 110.0') pp.create_line(net, bus3, bus4, length_km=50., std_type='149-AL1/24-ST1A 110.0') pp.create_line(net, bus4, bus2, length_km=40., std_type='149-AL1/24-ST1A 110.0') #create loads pp.create_load(net, bus2, p_mw=60, controllable=False) pp.create_load(net, bus3, p_mw=70, controllable=False) pp.create_load(net, bus4, p_mw=10, controllable=False) #create generators eg = pp.create_ext_grid(net, bus1, min_p_mw=-1000, max_p_mw=1000) g0 = pp.create_gen(net, bus3, p_mw=80, min_p_mw=0, max_p_mw=80, vm_pu=1.01, controllable=True) g1 = pp.create_gen(net, bus4, p_mw=100, min_p_mw=0, max_p_mw=100, vm_pu=1.01, controllable=True) # ## Loss Minimization # # We specify the same costs for the power at the external grid and all generators to minimize the overall power feed in. This equals an overall loss minimization: # In[2]: costeg = pp.create_poly_cost(net, 0, 'ext_grid', cp1_eur_per_mw=10) costgen1 = pp.create_poly_cost(net, 0, 'gen', cp1_eur_per_mw=10) costgen2 = pp.create_poly_cost(net, 1, 'gen', cp1_eur_per_mw=10) # We run an OPF: # In[3]: pp.runopp(net, delta=1e-16) # This function runs an Optimal Power Flow using the PYPOWER OPF. To make sure that the PYPOWER OPF converges, we decrease the power tolerance `delta` (the default value is `delta=1e-10`). The power tolerance `delta` is a measure of the extent to which exceeding of minimum and maximum power limits is tolerated. That is, in above case, the limits considered by the OPF for the generators are `min_p_mw - delta` and `max_p_mw + delta` as lower and upper bound respectively on the active power. # # Let's check the results: # In[4]: net.res_ext_grid # In[5]: net.res_gen # Since all costs were specified the same, the OPF minimizes overall power generation, which is equal to a loss minimization in the network. The loads at buses 3 and 4 are supplied by generators at the same bus, the load at Bus 2 is provided by a combination of the other generators so that the power transmission leads to minimal losses. # ## Individual Generator Costs # # Let's now assign individual costs to each generator. # # We assign a cost of 10 ct/kW for the external grid, 15 ct/kw for the generator g0 and 12 ct/kw for generator g1: # In[6]: net.poly_cost.cp1_eur_per_mw.at[costeg] = 10 net.poly_cost.cp1_eur_per_mw.at[costgen1] = 15 net.poly_cost.cp1_eur_per_mw.at[costgen2] = 12 # And now run an OPF: # In[7]: pp.runopp(net, delta=1e-16) # We can see that all active power is provided by the external grid: # In[8]: net.res_ext_grid # In[9]: net.res_gen # This makes sense, because the external grid has the lowest cost of all generators and we did not define any constraints. # # The dispatch costs are given in net.res_cost: # In[10]: net.res_cost # ### Transformer Constraint # # Since all active power comes from the external grid and subsequently flows through the transformer, the transformer is overloaded with a loading of about 145%: # In[11]: net.res_trafo.loading_percent # We now limit the transformer loading to 50%: # In[12]: net.trafo["max_loading_percent"] = 50 # (the max_loading_percent parameter can also be specified directly when creating the transformer) # and run the OPF: # In[13]: pp.runopp(net, delta=1e-16) # We can see that the transformer complies with the maximum loading: # In[14]: net.res_trafo.loading_percent # And power generation is now split between the external grid and generator 1 (which is the second cheapest generation unit): # In[15]: net.res_ext_grid # In[16]: net.res_gen # This comes of course with an increase in dispatch costs: # In[17]: net.res_cost # ### Line Loading Constraints # Wen now look at the line loadings: # In[18]: net.res_line.loading_percent # and run the OPF with a 50% loading constraint: # In[19]: net.line["max_loading_percent"] = 50 pp.runopp(net, delta=1e-16) # Now the line loading constraint is complied with: # In[20]: net.res_line.loading_percent # And all generators are involved in supplying the loads: # In[21]: net.res_ext_grid # In[22]: net.res_gen # This of course comes with a once again rising dispatch cost: # In[23]: net.res_cost # ### Voltage Constraints # # Finally, we have a look at the bus voltage: # In[24]: net.res_bus # and constrain it: # In[25]: net.bus["min_vm_pu"] = 1.0 net.bus["max_vm_pu"] = 1.02 pp.runopp(net, delta=1e-16) # We can see that all voltages are within the voltage band: # In[26]: net.res_bus # And all generators are once again involved in supplying the loads: # In[27]: net.res_ext_grid # In[28]: net.res_gen # This of course comes once again with rising dispatch costs: # In[29]: net.res_cost # ## DC OPF # # pandapower also provides the possibility of running a DC Optimal Power Flow: # In[30]: pp.rundcopp(net, delta=1e-16) # Since voltage magnitudes are not included in the DC power flow formulation, voltage constraints cannot be considered in the DC OPF: # In[31]: net.res_bus # Line and transformer loading limits are however complied with: # In[32]: net.res_line # In[33]: net.res_trafo # As are generator limits: # In[34]: net.gen # In[35]: net.res_gen # The cost function is the same for the linearized OPF as for the non-linear one: # In[36]: net.res_cost # ## Piecewise linear cost functions # # The OPF also offers piecewise linear cost functions. Let us first check the actual cost function setup: # In[37]: net.poly_cost # An element can either have polynomial costs or piecewise linear costs at the same time. So let us first delete the polynomial costs in order to avoid confusion and errors: # In[38]: net.poly_cost.drop(net.poly_cost.index.values, inplace=True) # The results above have been produced with linear polynomial cost functions. Let's try to reproduce the results using piecewise linear cost functions. Costs have to be defined for the whole range of generators and external grids: # In[39]: net.gen[["min_p_mw", "max_p_mw"]] # In[40]: net.ext_grid[["min_p_mw", "max_p_mw"]] # We define the piecewise linear cost as constant over the whole range to reproduce the polyomial costs defined above: # In[41]: pp.create_pwl_cost(net, 0, "gen", [[0, 80, 15]]) pp.create_pwl_cost(net, 1, "gen", [[0, 100, 12]]) pp.create_pwl_cost(net, 0, "ext_grid", [[-1000, 1000, 10]]) # Let us check the results from the previous OPF again! # In[42]: net.res_gen # In[43]: net.res_cost # We run the same OPF now with different cost function setup. We should get the exact same results: # In[44]: pp.rundcopp(net, delta=1e-16) # In[45]: net.res_gen # In[46]: net.res_cost # Now lets define real piecewise linear costs for generator 1. We define the costs as 12€/MW up to 70MW and 20€/MW from 70MW to 100MW: # In[47]: net.pwl_cost.points.loc[1] = [[0, 70, 12], [70, 100, 20]] # And run another OPF: # In[48]: pp.rundcopp(net, delta=1e-16) # Now we can see that generator 1 only dispatches 70MW, above which generator 0 becomes less expensive and is therefore dispatched: # In[49]: net.res_gen # ## Debugging # # For more information on the status of the OPF solver, set verbose=True: # In[50]: pp.runopp(net, verbose=True, delta=1e-16) # In[ ]: