PQ curve limits#

Introduction#

This example demonstrates how PQ curves are built in SHOP, and how the PQ curves are affected by different limits on production and discharge.

An introduction to PQ curves can be found here.

The model setup for this example is available in the following format:

For saving PQ curves, use the command save pq_curves /on. Note that only the PQ curves for the latest iteration is saved.

#Necessary imports
import pandas as pd
import plotly.graph_objs as go
import plotly.express as px

from pyshop import ShopSession

#Functions used in this example for building a basic SHOP model and running it
from pq_example_case import build_model, run_model

Create a SHOP session and import model#

We will use a simple example model with one reservoir and one plant.

#Create a standard ShopSession
shop=ShopSession()
#Build a basic SHOP model
build_model(shop)
#Display topology to the screen
display(shop.model.build_connection_tree())
../../_images/0696788983a27358fd484e29b5ffa6e3fa69ea78c8c1452b23f9551708177bb0.svg

Turbine efficiency curves#

In addition to the net generator head, SHOP uses the generator attributes gen_eff_curve and turb_eff_curves to build the PQ curves. The turb_eff_curves is the most important of these attributes, and describe the turbine efficiency as a function of discharge for different net head values. The plot below shows the turb_eff_curves for our generator.

for gen in shop.model.generator:
    #Get generator turbine efficiency curves
    turb_eff_curves=gen.turb_eff_curves.get()   

    #Create figure   
    fig = go.Figure()  
    x_discharge = pd.DataFrame([], dtype="float64")
    y_net_head = pd.Series([], dtype="float64")
    z_efficiency = pd.DataFrame([], dtype="float64")
    
    for curve in turb_eff_curves:
        if (len(x_discharge) > 0):
            x_discharge = pd.concat([x_discharge, pd.Series(curve.index)], axis=1)
        else:
            x_discharge = pd.concat([pd.Series(curve.index)], axis=1)
        
        if (len(y_net_head) > 0):
            y_net_head = pd.concat([y_net_head, pd.Series(curve.name)])
        else:
            y_net_head = pd.concat([pd.Series(curve.name)])
            
        if (len(z_efficiency) > 0):
            z_efficiency = pd.concat([z_efficiency, pd.Series(curve.values)], axis=1)
        else:
            z_efficiency = pd.concat([pd.Series(curve.values)], axis=1)
    
    title_name="<b> Turbine efficiency curves of "+gen.get_name()+"</b>"
    fig = go.Figure(data=[go.Surface(x=x_discharge.values.T, y=y_net_head.values, z=z_efficiency.values.T)])
    fig.update_layout(scene = dict(
                        xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)",
                        yaxis_title="<b>Net head</b> (meter)",
                        zaxis_title="<b>Efficiency</b> (%)"),
                title=title_name, 
                scene_camera_eye=dict(x=1.5, y=-1.8, z=1.24),
                width=700, height=500,
                margin=dict(r=10, b=10, l=10, t=50))            
    fig.show()

Run model and plot results#

We start by running the example model.

run_model(shop)

In our example case, the reservoir goes from being full to being empty, so that the net head varies from its maximum to its minimum.

for rsv in shop.model.reservoir: 
    # Reservoir water level trajectory
    fig = go.Figure()        
    water_level=rsv.head.get()
    fig.add_trace(go.Scatter(x=water_level.index, y=water_level.values, name="Reservoir water level", marker_color="red"))     
    
    # Regulation max water level 
    max_level=rsv.hrl.get()
    txy_max_level=pd.Series(index=water_level.index, dtype="float64")
    txy_max_level=txy_max_level.fillna(max_level)
    fig.add_trace(go.Scatter(x=txy_max_level.index, y=txy_max_level.values, name="Reservoir regulation max water level", marker_color="rgb(0, 0, 255)", line=dict(width=3, dash="dot"))) 

    # Regulation min water level . 
    min_level=rsv.lrl.get()
    txy_min_level=pd.Series(index=water_level.index, dtype="float64")
    txy_min_level=txy_min_level.fillna(min_level)
    fig.add_trace(go.Scatter(x=txy_min_level.index, y=txy_min_level.values, name="Reservoir regulation min water level", marker_color="rgb(0, 200, 0)", line=dict(width=3, dash="dot")))  

    change = water_level.values[-1] - water_level.values[0]
    change = "{:,.4f}".format(change)
    change_pencentage = (water_level.values[-1] - water_level.values[0])/(rsv.hrl.get() - rsv.lrl.get()) * 100
    change_pencentage = "{:,.2f}".format(change_pencentage)
    fig.update_layout(title="<b>Reservoir water level trajectory of "+rsv.get_name()+"</b><br> (Change "+str(change)+" meter, "+str(change_pencentage)+"%)<br>", xaxis_title="<b>Time</b> (Hour)", yaxis_title="<b>Water level</b> (meter above sea level)")
    fig.update_layout(legend=dict(orientation="h", yanchor="bottom", y=-0.5, xanchor="center", x=0.5))
    fig.show()

for plant in shop.model.plant:
    eff_head=plant.eff_head.get()
    fig = go.Figure() 
    fig.add_trace(go.Scatter(x=eff_head.index, y=eff_head.values, name=str(eff_head.name), mode="lines+markers", line=dict(width=2)))    
    fig.update_layout(title="<b>Effective head of "+gen.get_name(), xaxis_title="<b>Time</b>", yaxis_title="<b>Eff head </b> (m)", colorway=px.colors.qualitative.Light24)
    fig.show()
    
00:00Feb 26, 201806:0012:0018:0000:00Feb 27, 201806:0012:009092949698100
Reservoir water levelReservoir regulation max water levelReservoir regulation min water levelReservoir water level trajectory of Reservoir1 (Change -9.9900 meter, -99.90%)Time (Hour)Water level (meter above sea level)
00:00Feb 26, 201806:0012:0018:0000:00Feb 27, 201806:0012:00889092949698
Effective head of Plant1_G1TimeEff head (m)

The plots below show the generator’s original_pq_curves, convex_pq_curves and final_pq_curves. Since no other limits have been set, the PQ curves are limited by the minimum and maximum discharge points from the turb_eff_curves.

for gen in shop.model.generator: 

    #Get generator discharge and production
    discharge = gen.discharge.get()
    production = gen.production.get()
    
    #Get PQ curves
    original_pq_curve=gen.original_pq_curves.get()
    convex_pq_curve=gen.convex_pq_curves.get()
    final_pq_curve=gen.final_pq_curves.get()

    #Plot original PQ curves     
    fig = go.Figure() 
    for i, curve in enumerate(original_pq_curve):               
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=curve.index, y=curve.values, name=str(curve.name), mode="lines+markers", line=dict(width=2)))    
    fig.update_layout(title="<b>Original PQ curves of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Production</b> (MW)", colorway=px.colors.sequential.Plasma)
    fig.show()
    
    #Plot convex PQ curves
    fig = go.Figure() 
    for i, curve in enumerate(convex_pq_curve):   
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=curve.index, y=curve.values, name=str(curve.name), mode="lines+markers", line=dict(width=2))) 
    fig.update_layout(title="<b>Convex PQ curves of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Production</b> (MW)", colorway=px.colors.sequential.Plasma)
    fig.show()
    
    #Plot final PQ curves
    fig = go.Figure()
    for i, curve in enumerate(final_pq_curve):                
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=curve.index, y=curve.values, name=str(curve.name), mode="lines+markers", line=dict(width=2)))
            
        # Add working point for each time                                            
        current_discharge = discharge.loc[curve.name]
        current_production = production.loc[curve.name]
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=[current_discharge], y=[current_production], mode="markers", marker_symbol="x", marker=dict(color="Black", size=5), name="working point", showlegend=False))
    fig.add_trace(go.Scatter(x=[current_discharge], y=[current_production], mode="markers", marker_symbol="x", marker=dict(color="Black", size=5), name="working point"))
    fig.update_layout(title="<b>Final PQ curves of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Production</b> (MW)", colorway=px.colors.sequential.Plasma)
    fig.show()
20406080100102030405060708090
2018-02-26 00:00:002018-02-26 06:00:002018-02-26 12:00:002018-02-26 18:00:002018-02-27 00:00:002018-02-27 06:00:002018-02-27 12:00:00Original PQ curves of Plant1_G1Discharge (m​3/s)Production (MW)
20406080100102030405060708090
2018-02-26 00:00:002018-02-26 06:00:002018-02-26 12:00:002018-02-26 18:00:002018-02-27 00:00:002018-02-27 06:00:002018-02-27 12:00:00Convex PQ curves of Plant1_G1Discharge (m​3/s)Production (MW)
020406080100020406080
2018-02-26 00:00:002018-02-26 06:00:002018-02-26 12:00:002018-02-26 18:00:002018-02-27 00:00:002018-02-27 06:00:002018-02-27 12:00:00working pointFinal PQ curves of Plant1_G1Discharge (m​3/s)Production (MW)

Production limits#

We will now run the example case again, but this time wih stricter limits on production; p_min and p_max.

#Create a standard ShopSession
shop2=ShopSession()
#Build a basic SHOP model
build_model(shop2)
#Set the p_min and p_max attributes
for gen in shop2.model.generator:
    gen.p_min.set(20)
    gen.p_max.set(82)
#Run model
run_model(shop2)

Now the PQ curves are limited from below by the minimum production p_min. From above the curves are limited by the maximum production p_max while the net head is high, while for lower net heads the PQ curve is still limited by the maximum discharge from the turb_eff_curves.

for gen in shop2.model.generator: 
    original_pq_curve=gen.original_pq_curves.get()
    fig = go.Figure() 
    for i, curve in enumerate(original_pq_curve):               
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=curve.index, y=curve.values, name=str(curve.name), mode="lines+markers", line=dict(width=2)))    
    fig.add_trace(go.Scatter(x=[25,100], y=[gen.p_max.get()]*2, name="P_max", mode="lines", line=dict(dash="dash",width=2,color="black")))
    fig.add_trace(go.Scatter(x=[25,100], y=[gen.p_min.get()]*2, name="P_min", mode="lines", line=dict(dash="dash",width=2,color="black")))
    fig.update_layout(title="<b>Original PQ curves of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Production</b> (MW)", colorway=px.colors.sequential.Plasma)
    fig.show()
    
3040506070809010020304050607080
2018-02-26 00:00:002018-02-26 06:00:002018-02-26 12:00:002018-02-26 18:00:002018-02-27 00:00:002018-02-27 06:00:002018-02-27 12:00:00P_maxP_minOriginal PQ curves of Plant1_G1Discharge (m​3/s)Production (MW)

Discharge limits#

The default behaviour of SHOP is to find the discharge limits from the minimum discharge points of the turb_eff_curves. Explicit discharge limits can be set using the head-dependent generator attributes min_discharge and max_discharge. The same attributes are also available on the needle_combination object, which will take precedence over the generator attributes if both are defined.

We define and plot head dependent discharge limits below:

q_min=pd.Series([45.0, 69.0], index=[90.0, 100.0])
q_max=pd.Series([85.0, 109.0], index=[90.0, 100.0])

fig=go.Figure()
fig.add_trace(go.Scatter(x=q_min.values, y=q_min.index, name="min discharge"))
fig.add_trace(go.Scatter(x=q_max.values, y=q_max.index, name="max discharge"))
fig.update_layout(title="<b>Head dependent min and max discharge limits for "+gen.get_name(), yaxis_title="<b>Head</b> (m)", xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)")
fig.show()
50607080901001109092949698100
min dischargemax dischargeHead dependent min and max discharge limits for Plant1_G1Discharge (m​3/s)Head (m)

We set the discharge limits in SHOP and rerun the model.

#Create a standard ShopSession
shop3=ShopSession()
#Build a basic SHOP model
build_model(shop3)
#set the min_discharge and max_discharge attributes
q_min=pd.Series([45.0, 69.0], index=[90.0, 100.0])
q_max=pd.Series([85.0, 109.0], index=[90.0, 100.0])
for gen in shop3.model.generator:
    gen.min_discharge.set(q_min)
    gen.max_discharge.set(q_max)
#Run model
run_model(shop3)

Now we see that the PQ curves are limited by the head-dependent min_discharge and max_discharge attributes.

for gen in shop3.model.generator: 
    original_pq_curve=gen.original_pq_curves.get() 
    fig = go.Figure() 
    for i, curve in enumerate(original_pq_curve):              
        if i%6==0: #plot every 6th hour only
            fig.add_trace(go.Scatter(x=curve.index, y=curve.values, name=str(curve.name), mode="lines+markers", line=dict(width=2)))    
    fig.update_layout(title="<b>Original PQ curves of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Production</b> (MW)", colorway=px.colors.sequential.Plasma)
    fig.show()
506070809010030405060708090
2018-02-26 00:00:002018-02-26 06:00:002018-02-26 12:00:002018-02-26 18:00:002018-02-27 00:00:002018-02-27 06:00:002018-02-27 12:00:00Original PQ curves of Plant1_G1Discharge (m​3/s)Production (MW)

As shown in the figure below, the optimized discharge of the generator is limited by the set max_discharge limit when the effective generator head is low. For higher head values, the generator discharges 90 m3/s since this is the best operating point for the plant.

Note that the max_discharge limit was not defined for head values lower than 90 m, but the effective head of the generator is lower than this for several time steps. SHOP assumes that the limits defined for the lowest and highest head values are valid for all head values outside the range covered by the input data, which can be seen in the figure below.

for gen in shop3.model.generator: 
    head = gen.eff_head.get()
    discharge = gen.discharge.get()
    q_min = gen.min_discharge.get()
    q_max = gen.max_discharge.get()
    
    fig = go.Figure() 
    fig.add_trace(go.Scatter(x=discharge.values, y=head.values, name="discharge",mode="markers"))
    fig.add_trace(go.Scatter(x=q_min.values, y=q_min.index, name="min discharge",mode="lines"))
    fig.add_trace(go.Scatter(x=q_max.values, y=q_max.index, name="max discharge",mode="lines"))    
    fig.update_layout(title="<b>Discharge and effective head of "+gen.get_name(), xaxis_title="<b>Discharge</b> (m<sup>3</sup>/s)", yaxis_title="<b>Effective head</b> (m)", colorway=px.colors.sequential.Plasma)
    fig.show()
    
5060708090100889092949698100
dischargemin dischargemax dischargeDischarge and effective head of Plant1_G1Discharge (m​3/s)Effective head (m)