Plant reserve strategies#
SHOP provides several ways of limiting the amount of reserve capacity that can be allocated on a single plant. This example will show how to to limit the plant production including upward/downward reserves based on the calculated TXY attributes max_prod_all, max_prod_available, max_prod_spinning, and min_prod_spinning. These attributes are calculated in every SHOP iteration, and so the resulting max/min limit constraints will change dynamically.
The three output maximum production TXYs on plant level can be used as upper limits for the sum plant production plus all upward reserve capacity by specifying the string attribute max_prod_reserve_strategy on plant level, or by setting the global_settings string attribute plant_max_prod_reserve_strategy. The command set plant_max_prod_reserve_strategy can also be used to alter the attribute on global_settings. The valid strategies are “OFF”, “ALL”, “AVAILABLE”, and “SPINNING” (upper case will be automatically apllied in SHOP), and corresponds to choosing the similarly named max_prod_<> attribute as the upper limit for plant production and upward reserve capacity. Note that the default strategy on plant level is “NOT SET”, which means that the strategy on global_settings will be used (default is “OFF”, meaning no constraints of this type are built). Whenever the strategy on plant level is set to something other than “NOT SET”, it will take precedence over the global strategy.
Similar commands and attributes are available for using min_prod_spinning as a lower limit for plant production minus downward reserve capacity. Only the “SPINNING” strategy is available for the minimum plant limit since there is only one minimum production that is calculated on the plant object. The min_prod_spinning limit is usually the same as the sum of the individual generator minimum limits since it is typically less sensitive to head dependencies, but is relevant when there is a minimum plant production or minimum plant discharge constraint active on the plant.
Limiting the maximum production and reserve delivery on a plant#
Running a model without limits on plant reserve delivery#
#Necessary imports used in all examples
import pandas as pd
from pyshop import ShopSession
import plotly.graph_objects as go
pd.options.plotting.backend = "plotly"
#Functions used in this example for building and solving a SHOP model
from reserve import build_model, run_model
#For convenience, we create a function to add an RR_UP market to the basic model
def get_model_with_rr_up() -> ShopSession:
#Create a new shop session
shop=ShopSession()
build_model(shop)
#Add a reserve_group object for rr_up delivery and add all generators to the group
rr_group = shop.model.reserve_group.add_object("rr_group")
rr_group.rr_up_obligation.set(0)
for gen in shop.model.generator:
gen.connect_to(rr_group)
#Add an rr_up market where rr_up can be sold for 10 €/MWh
m = shop.model.market.add_object("rr_up_market")
m.market_type.set("RR_UP")
m.sale_price.set(10)
m.max_sale.set(1000)
m.connect_to(rr_group)
return shop
#Get a model with an RR_UP market
shop = get_model_with_rr_up()
#Display topology to the screen
display(shop.model.build_connection_tree())
#Optimize model
run_model(shop)
#Save objective function value for later
obj_without_plant_max = shop.model.objective.average_objective.grand_total.get()
#Define a function to plot the plant production and reserve delivery including max_prod limits
def plot_plant_prod_and_up_reserve(shop:ShopSession) -> None:
for plant in shop.model.plant:
prod = plant.production.get()
rr_up = sum(gen.rr_up_delivery.get() for gen in plant.generators)
gen_max = sum(gen.max_prod_individual.get() for gen in plant.generators)
p_max_all = plant.max_prod_all.get()
p_max_available = plant.max_prod_available.get()
p_max_spinning = plant.max_prod_spinning.get()
t = prod.index
name = plant.get_name()
fig = go.Figure(layout={'title':f"{name}: production and reserves",'xaxis_title':"Time",'yaxis_title':"Production and reserves [MW]"})
fig.add_trace(go.Scatter(name="max_prod_spinning",x=t,y=p_max_spinning.values,line={'color': "blue", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="max_prod_available",x=t,y=p_max_available.values,line={'color': "red", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="max_prod_all",x=t,y=p_max_all.values,line={'color': "black", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="gen_max",x=t,y=gen_max.values,line={'color': "magenta", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="production",x=t,y=prod.values,line={'color': "black", 'width': 1},line_shape='hv'))
fig.add_trace(go.Scatter(name="RR_UP",x=t, y=(prod + rr_up).values ,fill='tonexty',line={'color': "light gray", 'width': 0},line_shape='hv'))
fig.show()
The production and RR_UP reserve capacity allocation on each plant is shown in the figures below.
It is lucrative to sell a lot of RR_UP capacity in this SHOP run due to the relatively high reserve market price. The calculated maximum plant production considering all, available, and spinning generators are plotted in the same figures. Since there are no generators that are committed to be off in this model, max_prod_all and max_prod_available are identical, while max_prod_spinning is always lower since it only considers spinning units.
Note that the allocated RR_UP capacity is above all plant max_prod attributes in many hours. This happens because the reserve constraints are built per generator, and the maximum limit for production plus reserves are set by the calculated max_prod_individual attribute. This maximum production limits assume that all other generators in the plant operate at their current production level, while max_prod_all on the plant is calculated assuming that all generators not on maintenance are operating at max. The sum of max_prod_individual for all generators in the plant is plotted in the figures which clearly shows that this is the constraining limit.
#Plot results
plot_plant_prod_and_up_reserve(shop)
Including maximum plant reserve limits#
To add stricter limits to the sum reserve delivery on each plant, the global attribute plant_max_prod_reserve_strategy is set to “ALL” while keeping the rest of the model identical:
#Get the same model as earlier
shop = get_model_with_rr_up()
#Specify a global max_prod reserve strategy
shop.model.global_settings.global_settings.plant_max_prod_reserve_strategy.set("ALL")
#Optimize model
run_model(shop)
#Save objective function value for later
obj_with_plant_max = shop.model.objective.average_objective.grand_total.get()
#Plot the results from the new run
plot_plant_prod_and_up_reserve(shop)
Using max_prod_all as an upper limit now constrains the total production plus RR_UP delivery on each plant, and this has caused the optimal solution to change slightly. The net profit in the solution is also reduced since SHOP can’t sell as much RR_UP as previously:
print(f"The net profit loss of using the max constraint is: {obj_with_plant_max - obj_without_plant_max:.2f} €")
The net profit loss of using the max constraint is: 15105.00 €
Limiting the minimum production and reserve delivery on a plant#
As mentioned in the introduction, minimum limits for plant production minus downward reserve capacity is usually only relevant when there is a minimum production or discharge limit on the plant. These limits are not seen by the individual generator reserve limits, but will be captured in the min_prod_spinning attribute on plant level.
Running a model without minimum limits on plant reserve delivery#
The same model as in the first example is run without any downward reserve limit on plant level, but this time RR_DOWN is sold instead of RR_UP. In addition, Plant1 and Plant2 have defined minimum production and discharge limits, respectively.
#Define a model with an RR_DOWN market and min_p_constr and min_q_constr on plants
def get_model_with_rr_down() -> ShopSession:
#Create the same model as earlier
shop=ShopSession()
build_model(shop)
rr_group = shop.model.reserve_group.add_object("rr_group")
rr_group.rr_down_obligation.set(0)
for gen in shop.model.generator:
gen.connect_to(rr_group)
#Add a minimum production limit on Plant1
plant1 = shop.model.plant["Plant1"]
plant1.min_p_constr.set(150)
#Add a minimum discharge limit on Plant2
plant2 = shop.model.plant["Plant2"]
plant2.min_q_constr.set(300)
m = shop.model.market.add_object("rr_down_market")
m.market_type.set("RR_DOWN")
m.sale_price.set(10)
m.max_sale.set(1000)
m.connect_to(rr_group)
return shop
#Get a model with a RR_DOWN market and minimum production and plant limits
shop = get_model_with_rr_down()
#Optimize model
run_model(shop)
#Save objective function value for later
obj_without_plant_min = shop.model.objective.average_objective.grand_total.get()
#Function to plot plant production and downward reserve capacity with min_prod limits
def plot_plant_prod_and_down_reserve(shop:ShopSession):
for plant in shop.model.plant:
prod = plant.production.get()
rr_down = sum(gen.rr_down_delivery.get() for gen in plant.generators)
gen_min = sum(gen.min_prod_individual.get()*gen.committed_out.get() for gen in plant.generators)
p_min_spinning = plant.min_prod_spinning.get()
t = prod.index
name = plant.get_name()
fig = go.Figure(layout={'title':f"{name}: production and reserves",'xaxis_title':"Time",'yaxis_title':"Production and reserves [MW]"})
fig.add_trace(go.Scatter(name="min_prod_spinning",x=t,y=p_min_spinning.values,line={'color': "blue", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="gen_min",x=t,y=gen_min.values,line={'color': "magenta", 'width': 2,'dash':"dash"},line_shape='hv'))
fig.add_trace(go.Scatter(name="production",x=t,y=prod.values,line={'color': "black", 'width': 1},line_shape='hv'))
fig.add_trace(go.Scatter(name="RR_DOWN",x=t, y=(prod - rr_down).values ,fill='tonexty',line={'color': "light gray", 'width': 0},line_shape='hv'))
fig.show()
#Plot the results of the base model
plot_plant_prod_and_down_reserve(shop)
It is clear from the figures above that the generators deliver RR_DOWN reserve capacity below the minimum production and discharge limits in the plant shown by the min_prod_spinning attribute. Note that the constant min_q_constr on Plant2 (300 m3/s) creates min_prod_spinning that varies with time due to the head dependency.
Including minimum plant reserve limits#
To inclue these limits, a new identical model is built with plant_min_prod_reserve_strategy set to “SPINNING”:
#Generate the same model as before
shop = get_model_with_rr_down()
#Specify a global min_prod reserve strategy
shop.model.global_settings.global_settings.plant_min_prod_reserve_strategy.set("SPINNING")
#Optimize model
run_model(shop)
#Save objective function value for later
obj_with_plant_min = shop.model.objective.average_objective.grand_total.get()
#Plot the results from the new run
plot_plant_prod_and_down_reserve(shop)
The reserve capacity allocation now respect the minimum constraints of the plant. As was the case in the first example, the net profit in SHOP has been reduced after adding the minimum reserve constraint:
print(f"The net profit loss of using the min constraint is: {obj_with_plant_min - obj_without_plant_min:.2f} €")
The net profit loss of using the min constraint is: 41748.30 €
Precautions#
Unlike attributes like max_p_constr which adds a constant upper limit on the plant production in every hour, the upper limits described in this example are dynamically calculated between iterations in SHOP. This means that the max_prod_<> attribute calculated in the last iteration is used as an upper limit in the next iteration. When comparing the resulting production and reserve capacity to the max_prod_<> attribute, it might appear that SHOP is violating the constraint since the max_prod_<> attribute was re-calculated after the optimization model has solved. This is especially true in full iterations where unit commitment is not fixed. The “SPINNING” strategy is also more prone to big changes between iterations since it depends on which units are committed on and off in each hour. Using the “ALL” and “AVAILABLE” strategies is less prone to big variations between iterations since they include offline units in the calculation. Only activating the plant reserve strategies in incremental iterations is also a possibility that will mitigate this problem.
reserve_cap.py#
import pandas as pd
import numpy as np
def build_model(shop):
starttime = pd.Timestamp('2018-01-23 00:00:00')
endtime = pd.Timestamp('2018-01-26')
shop.set_time_resolution(starttime=starttime, endtime=endtime, timeunit="hour", timeresolution=pd.Series(index=[starttime],data=[1]))
rsv1 = shop.model.reservoir.add_object('Reservoir1')
rsv1.max_vol.set(39)
rsv1.lrl.set(860)
rsv1.hrl.set(905)
rsv1.vol_head.set(pd.Series([860, 906, 907], index=[0, 39, 41.66], name=0))
rsv2 = shop.model.reservoir.add_object('Reservoir2')
rsv2.max_vol.set(97.5)
rsv2.lrl.set(650)
rsv2.hrl.set(679)
rsv2.vol_head.set(pd.Series([650, 679, 680], index=[0, 97.5, 104.15], name=0))
plant1 = shop.model.plant.add_object('Plant1')
plant1.outlet_line.set(672)
plant1.main_loss.set([0])
plant1.penstock_loss.set([0.001])
plant1.mip_flag.set(1)
for gen_no in range(2):
gen=shop.model.generator.add_object(f"{plant1.get_name()}_G{str(gen_no+1)}")
gen.connect_to(plant1)
gen.penstock.set(1)
gen.p_min.set(60)
gen.p_max.set(120)
gen.p_nom.set(120)
gen.startcost.set(300)
gen.gen_eff_curve.set(pd.Series([100, 100], index=[60, 120]))
gen.turb_eff_curves.set([pd.Series([85.8733, 87.0319, 88.0879, 89.0544, 89.9446, 90.7717, 91.5488, 92.2643, 92.8213, 93.1090, 93.2170, 93.0390, 92.6570, 92.1746],
index=[28.12, 30.45, 32.78, 35.11, 37.45, 39.78, 42.11, 44.44, 46.77, 49.10, 51.43, 53.76, 56.10, 58.83],
name=170),
pd.Series([86.7321, 87.9022, 88.9688, 89.9450, 90.8441, 91.6794, 92.4643, 93.1870, 93.7495, 94.0401, 94.1492, 93.9694, 93.5836, 93.0964],
index=[28.12, 30.45, 32.78, 35.11, 37.45, 39.78, 42.11, 44.44, 46.77, 49.10, 51.43, 53.76, 56.10, 58.83],
name=200),
pd.Series([87.5908, 88.7725, 89.8497, 90.8355, 91.7435, 92.5871, 93.3798, 94.1096, 94.6777, 94.9712, 95.0813, 94.8998, 94.5101, 94.0181],
index=[28.12, 30.45, 32.78, 35.11, 37.45, 39.78, 42.11, 44.44, 46.77, 49.10, 51.43, 53.76, 56.10, 58.83],
name=230)])
plant2 = shop.model.plant.add_object('Plant2')
plant2.outlet_line.set(586)
plant2.main_loss.set([0])
plant2.penstock_loss.set([0.0001,0.0002])
plant2.mip_flag.set(1)
for gen_no in range(4):
gen=shop.model.generator.add_object(f"{plant2.get_name()}_G{str(gen_no+1)}")
gen.connect_to(plant2)
if gen_no == 0:
gen.penstock.set(1)
gen.p_min.set(100)
gen.p_max.set(180)
gen.p_nom.set(180)
gen.startcost.set(300)
gen.gen_eff_curve.set(pd.Series([100, 100], index=[100, 180]))
gen.turb_eff_curves.set([pd.Series([92.7201, 93.2583, 93.7305, 94.1368, 94.4785, 94.7525, 94.9606, 95.1028, 95.1790, 95.1892, 95.1335, 95.0118, 94.8232, 94.5191],
index=[126.54, 137.03, 147.51, 158.00, 168.53, 179.01, 189.50, 199.98, 210.47, 220.95, 231.44, 241.92, 252.45, 264.74],
name=60)])
else:
gen.penstock.set(2)
gen.p_min.set(30)
gen.p_max.set(55)
gen.p_nom.set(55)
gen.startcost.set(300)
gen.gen_eff_curve.set(pd.Series([100, 100], index=[30, 55]))
gen.turb_eff_curves.set([pd.Series([83.8700, 85.1937, 86.3825, 87.4362, 88.3587, 89.1419, 89.7901, 90.3033, 90.6815, 90.9248, 91.0331, 91.0063, 90.8436, 90.4817],
index=[40.82, 44.20, 47.58, 50.97, 54.36, 57.75, 61.13, 64.51, 67.89, 71.27, 74.66, 78.04, 81.44, 85.40],
name=60)])
rsv1.connect_to(plant1)
plant1.connect_to(rsv2)
rsv2.connect_to(plant2)
rsv1.start_head.set(900)
rsv2.start_head.set(670)
rsv1.energy_value_input.set(30)
rsv2.energy_value_input.set(10)
rsv2.inflow.set(pd.Series([60], [starttime]))
da = shop.model.market.add_object('Day_ahead')
da.sale_price.set(pd.DataFrame([32.992,31.122,29.312,28.072,30.012,33.362,42.682,74.822,77.732,62.332,55.892,46.962,42.582,40.942,39.212,39.142,41.672,46.922,37.102,32.992,31.272,29.752,28.782,28.082,27.242,26.622,25.732,25.392,25.992,27.402,28.942,32.182,33.082,32.342,30.912,30.162,30.062,29.562,29.462,29.512,29.672,30.072,29.552,28.862,28.412,28.072,27.162,25.502,26.192,25.222,24.052,23.892,23.682,26.092,28.202,30.902,31.572,31.462,31.172,30.912,30.572,30.602,30.632,31.062,32.082,36.262,34.472,32.182,31.492,30.732,29.712,28.982],
index=[starttime + pd.Timedelta(hours=i) for i in range(0,72)]))
da.max_sale.set(pd.Series([9999], [starttime]))
da.buy_price.set(da.sale_price.get()+0.002)
da.max_buy.set(pd.Series([9999], [starttime]))
settings = shop.model.global_settings.global_settings
settings.mipgap_rel.set(0)
settings.mipgap_abs.set(0)
def run_model(shop):
shop.start_sim([], ['3'])
shop.set_code(['incremental'], [])
shop.start_sim([], ['5'])