Pyomo optimisation not working (gas plant dispatch)
Asked Answered
P

1

1

background

I am trying to write an pyomo script to optimally dispatch a gas plant based on perfect foresight of electricity prices. I believe I am 90% of the way there, just a few issues.

Problem

My script works, but the solver is never dispatching the plant, even where it should be, in the example provided below, manually I can calculate at least $8131 of potential profit.

I suspect the reason for my zero results is due to how I've written the constraints, of which there are 2;

  1. Gas Plant takes 10 minutes to boot up from a cold start
  2. Once warmed up, the gas plant has a min load it must operate at/above.
  3. Gas Plant can only consume 9000 GJ of gas in a single day

Specifically on further testing, I think it is the 'gas_volume_used' constraint which is causing the issue.

Help Requested

Could someone please have a look at my code and see what I am missing in the constraint equations?

Code

import pandas as pd
from pyomo.environ import *
from pyomo.core import *


# ---------------------
# Main Functions
# ---------------------
def model_to_df(model, first_period, last_period):

    # Need to increase the first & last period by 1 because of pyomo indexing
    # and add 1 to the value of last model period because of the range
    periods = range(model.T[first_period + 1], model.T[last_period + 1] + 1)
    rrp = [model.P.extract_values()[None][i] for i in periods]
    Gascold = [value(model.Gascold[i]) for i in periods]
    Gaswarm = [value(model.Gaswarm[i]) for i in periods]
    Gashot = [value(model.Gashot[i]) for i in periods]
    GasUnit_mw = [value(model.GasUnit_mw[i]) for i in periods]
    GasUse = [value(model.GasUse[i]) for i in periods]

    df_dict = dict(
        period=periods,
        rrp=rrp,
        Gascold=Gascold,
        Gaswarm=Gaswarm,
        Gashot=Gashot,
        GasUnit_mw=GasUnit_mw,
        GasUse=GasUse
    )

    df = pd.DataFrame(df_dict)

    return df


def optimize_year(df, gunits, gnet, gprice, vom, cold_gas_p_unit, hr, max_gas, sys_use,
                  first_model_period, last_model_period):

    """
    Optimize behavior of a gas plant over a time period
    Assume perfect foresight of electricity prices.

    Parameters
    ----------
    df : dataframe
        dataframe with columns of hourly LBMP and the hour of the year
    gunits : int
        number of gas turbines
    gnet : double
        size in MW's of each gas turbine
    gprice : double
        the price of gas $/GJ
    vom  : double
        the variable cost of gas $/MWh dispatched
    cold_gas_p_unit  : double
        the amount of gas in GJ used in 5 minutes while booting up from cold start
    hr  : double
        heat rate, the amount of gas consumed per MWh produced (GJ/MWh)
    sys_use  : double
        auxillary gas usage, percentage
    max_gas  : double
        the maximum amount of gas (GJ) available to use in a period
    first_model_period : int, optional
        Set the first period of the year to be considered in the optimization
    last_model_period : int, optional
        Set the last period of the year to be considered in the optimization

    Returns
    -------
    dataframe
        Gas plant operational stats and time stamp
    """

    # Filter the data
    df = df.loc[first_model_period:last_model_period, :]

    model = ConcreteModel()

    # Define model parameters
    model.T = Set(initialize=df.period.tolist(), doc='periods of year', ordered=True)
    model.P = Param(initialize=df.rrp.tolist(), doc='price for each period', within=Any)

    model.MaxGas = Param(initialize=(gnet/12) * gunits * hr * (1+sys_use), doc='Max gas usage (GJ) in interval', within=Any)
    model.MaxVol = Param(initialize=max_gas, doc='Max gas usage (GJ) in 24 hour period', within=Any)
    model.MaxMW = Param(initialize=(gnet/12) * gunits, doc='Max MWs', within=Any)

    model.Gascold = Var(model.T, doc='binary', within=Binary, initialize=1)
    model.Gaswarm = Var(model.T, doc='binary', within=Binary, initialize=0)
    model.Gashot = Var(model.T, doc='binary', within=Binary, initialize=0)
    model.GasUse = Var(model.T, bounds=(0, model.MaxGas))
    model.GasUnit_mw = Var(model.T, bounds=(0, model.MaxMW))

    # *********************
    # GAS CONSTRAINTS
    # *********************
    "Ensure that all three Gas State flags sum to 1"
    def set_on(model, t):
        return model.Gascold[t] + model.Gaswarm[t] + model.Gashot[t] == 1

    model.limit_flags = Constraint(model.T, rule=set_on)

    "Set t0 to GAS COLD = 1"
    def gas_cold(model, t):
        if t == model.T.first():
            return model.Gascold[t] == 1
        else:
            return model.Gascold[t] >= 0

    model.gas_cold_check = Constraint(model.T, rule=gas_cold)

    "Gas can only warm up from cold"
    def gas_warm(model, t):
        if t < 2:
            return model.Gaswarm[t] == 0
        elif (model.Gaswarm[t-2] + model.Gaswarm[t-1] + model.Gascold[t-1]) == 1:
            return model.Gaswarm[t] >= 0
        else:
            return model.Gaswarm[t] == 0

    model.gas_warm_check = Constraint(model.T, rule=gas_warm)

    "gas can only get hot from warm"
    def gas_hot(model, t):
        if t <= 2:
            return model.Gashot[t] == 0
        elif value(model.Gaswarm[t-2] + model.Gaswarm[t-1] + model.Gashot[t-2] + model.Gashot[t-1]) < 2:
            return model.Gashot[t] == 0
        else:
            return model.Gashot[t] >= 0

    model.gas_hot_check = Constraint(model.T, rule=gas_hot)

    "Gas hot min load"
    def gas_hot_min_load(model, t):
        if model.Gashot[t] == 1:
            return model.GasUnit_mw[t] >= 30 / 12
        else:
            return model.GasUnit_mw[t] == 0

    model.gas_hot_min_load_check = Constraint(model.T, rule=gas_hot_min_load)

    "Gas volume used"
    def gas_volume_used(model, t):
        'how much gas is being used?'
        return model.GasUse[t] == (model.GasUnit_mw[t] * hr * (1+sys_use)) + (model.Gaswarm[t] * gunits * cold_gas_p_unit * (1+sys_use))

    model.gas_vol = Constraint(model.T, rule=gas_volume_used)

    "only 9000 GJ of gas can be used in a single day"
    def daily_max_gas(model, t):
        min_t = model.T.first()
        max_t = model.T.last()

        # Check all t until the last 24 hours
        if t <= max_t:
            return sum(model.GasUse[i] for i in range(min_t, max_t+1)) <= model.MaxVol
        else:
            return Constraint.Skip

    model.max_gas_vol = Constraint(model.T, rule=daily_max_gas)

    # Define the income, expenses, and profit
    gas_income = sum(df.loc[t, 'rrp'] * model.GasUnit_mw[t] for t in model.T)
    gas_expenses = sum(model.GasUse[t] * gprice + model.GasUnit_mw[t] * vom for t in model.T)
    profit = gas_income - gas_expenses
    model.objective = Objective(expr=profit, sense=maximize)

    # Solve the model
    solver = SolverFactory('glpk')
    solver.solve(model)

    results_df = model_to_df(model, first_period=first_model_period, last_period=last_model_period)
    results_df['local_time'] = df.loc[:, 'local_time']

    return results_df


# *********************
# RUN SCRIPT
# *********************
# Import file
df = pd.read_csv('raw_prices.csv')

# New Index
df['period'] = df.reset_index().index

# get current Year
first_model_period = 0
last_model_period = df['period'].iloc[-1]

# Gas Inputs
gunits = 3 # No units
gnet = 58.5 # MW/unit
srmc = 115.49 # $/MWh
vom = 6.18 # $/MWh
gprice = 10.206 # $/GJ
cold_gas_p_unit = 15.75 # GJ/unit/5mins
hr = 10.5 # GJ/MWh
sys_use = 0.02 # %
max_gas = 9000 # GJ

# Run Model
results_df = optimize_year(df=df, gunits=gunits, gnet=gnet, gprice=gprice, vom=vom, cold_gas_p_unit=cold_gas_p_unit,
                           hr=hr, max_gas=max_gas, sys_use=sys_use,
                           first_model_period=first_model_period, last_model_period=last_model_period)

# ---------------------
# Write the file out and remove the index
# ---------------------
print('ran successfully')
results_df.to_csv('optimised_output.csv', index=False)

Example Data

local_time      rrp
3/07/2022 16:40 64.99
3/07/2022 16:45 73.10744
3/07/2022 16:50 74.21746
3/07/2022 16:55 95.90964
3/07/2022 17:00 89.60728
3/07/2022 17:05 90.37018
3/07/2022 17:10 92.52947
3/07/2022 17:15 100.1449
3/07/2022 17:20 103.70199
3/07/2022 17:25 290.2848
3/07/2022 17:30 115.40232
3/07/2022 17:35 121.00009
3/07/2022 17:40 294.62543
3/07/2022 17:45 121
3/07/2022 17:50 294.69446
3/07/2022 17:55 124.90664
3/07/2022 18:00 117.21929
3/07/2022 18:05 115.60193
3/07/2022 18:10 121
3/07/2022 18:15 148.20186
3/07/2022 18:20 123.11763
3/07/2022 18:25 120.99996
3/07/2022 18:30 121
3/07/2022 18:35 121
3/07/2022 18:40 121
3/07/2022 18:45 120.77726
3/07/2022 18:50 105.44435
3/07/2022 18:55 104.22928
3/07/2022 19:00 102.26646
3/07/2022 19:05 99.5896
3/07/2022 19:10 95.5881
3/07/2022 19:15 88
3/07/2022 19:20 88
3/07/2022 19:25 88
3/07/2022 19:30 96.97537
3/07/2022 19:35 88
3/07/2022 19:40 86.39322
3/07/2022 19:45 88
3/07/2022 19:50 85.97703
3/07/2022 19:55 86
3/07/2022 20:00 87.61047
3/07/2022 20:05 88
3/07/2022 20:10 88
3/07/2022 20:15 85.34908
3/07/2022 20:20 85.1
3/07/2022 20:25 71.01181
3/07/2022 20:30 84.88422
3/07/2022 20:35 85.39669
3/07/2022 20:40 86.02542
3/07/2022 20:45 84.87425
3/07/2022 20:50 70.0652
3/07/2022 20:55 70.65106
3/07/2022 21:00 70.53063
3/07/2022 21:05 68.27
3/07/2022 21:10 68.86159
3/07/2022 21:15 68.32018
3/07/2022 21:20 67.30473
3/07/2022 21:25 66.78292
3/07/2022 21:30 66.02195
3/07/2022 21:35 66.40755
Peptide answered 12/3, 2022 at 23:28 Comment(3)
I think I see what you are trying to do here, but this model has some serious faults. The major one is that you are trying to use conditional statements that depend on variable values in your constraints. That isn't legal. The constraint building functions need to return expressions before the value of any variable is known, so those expressions need to be linearized. Also, your gas on/off needs to be a binary variable, and you may need to redefine it. Also, your pricing parameter needs to be indexed, so pass it in as a dictionary from df not a list.Schell
I'd suggest chopping your data down to just a few time periods and printing the model out before trying to solve and ensure that it reads as you expect. I'll try to review again soon when I have some spare time.Schell
Hi @airsquid thanks for your reply. Sounds like I've got a pyomo version of a circular reference. I haven't been able to resolve it so far, but will keep at it. I appreciate you taking your time out to review, fingers crossed you have better luck figuring it out than me. thanks again, appreciate the assist.Peptide
S
1

Well, I went a little geek on this. Got hooked, kinda interesting problem.

So, I made a bunch of changes and left some of your code in this example. I also chopped down a handful of the cost variables and made them rather simple as I was getting a little lost in the sauce and so that I was (mostly) convinced things were working, so the units/conversions/costs are a bit nonsensical now, but should be easily recovered.

Hopefully there are a couple concepts in here that you can use as you work through this. A few notes...

  • Needed a binary variable to indicate that the plant was started, and another to keep track of whether it was "running" or not in a particular period, these were linked with a constraint
  • Added a little trickery with the time windows to make a rolling evaluation period for total gas use
  • Added a minimum use for the plant to run or else once it was "started" it could arbitrarily run with 0 gas when not profitable, now a minimum-run or off decision is forced

Plot shows pretty convincing evidence that it is running as hoped, it starts up, runs at max blast when price is high, and adheres to rolling gas limit, then shuts down and does it again.

enter image description here

Code

import pandas as pd
import numpy as np
from pyomo.environ import *
#from pyomo.core import *   # not needed
import matplotlib.pyplot as plt

# Import file
df = pd.read_csv('raw_prices_full.csv')

# New Index
df['period'] = df.reset_index().index

print(df.head())
print(df.info())

# get current Year
first_model_period = 0
last_model_period = df['period'].iloc[-1]

# Gas Inputs
gunits = 2 # No units
gnet = 20 # MW/unit
#srmc = 115.49 # $/MWh
#vom = 6.18 # $/MWh
gprice = 10 # $/GJ
startup_gas_rate = 8 # GJ/unit/5mins
min_running = 5 # GJ/unit/5mins   <--- assumed minimal output...  or plant could "run" at zero
hr = 10 # GJ/MWh
sys_use = 0.10 # % overhead in GJ of gas
max_gas = 100 # GJ/30 minutes (6 periods), cut down for testing

# Filter the data
df = df.loc[first_model_period:last_model_period, :]

model = ConcreteModel()

# Define model parameters
model.T = Set(initialize=df.period.tolist(), doc='periods of year', ordered=True)
# P needs to be indexed by T, and should be init by dictionary
model.P = Param(model.T, initialize=df.rrp.to_dict(), doc='price for each period', within=NonNegativeReals)  
model.MaxGas = Param(initialize=max_gas, doc='Max gas usage (GJ) in 24 hour period', within=NonNegativeReals)

# variables
model.turn_on = Var(model.T, domain=Binary)         # decision to turn the plant on in period t, making it available at t+2
model.plant_running = Var(model.T, domain=Binary)   # plant is running (able to produce heat) in period t.  init is not necessary
model.total_gas = Var(model.T, bounds=(0, gunits*gnet / 12 * hr * (1+sys_use)))  # the total gas flow in any period [GJ/5min]
model.gas_for_power = Var(model.T, domain=NonNegativeReals )  # the gas flow for power conversion at any period [GJ/5min]
model.profit = Var(model.T)  # non-essential variable, just used for bookeeping

# *********************
# GAS CONSTRAINTS
# *********************

# logic for the plant_running control variable
def running(model, t):
    # plant cannot be running in first 2 periods
    if t < 2:
        return model.plant_running[t] == 0
    else:
        # plant can be running if it was running in last period or started 2 periods ago.
        return model.plant_running[t] <= model.plant_running[t-1] + model.turn_on[t-2]
model.running_constraint = Constraint(model.T, rule=running)

# charge the warmup gas after a start decision.  Note the conditions in this constraint are all
# mutually exclusive, so there shouldn't be any double counting
# this will constrain the minimum gas flow to either the startup flow for 2 periods, 
# the minimum running flow, or zero if neither condition is met
def gas_mins(model, t):
    # figure out the time periods to inspect to see if a start event occurred...
    if t==0:
        possible_starts = model.turn_on[t]
    else:
        possible_starts = model.turn_on[t] + model.turn_on[t-1]
    return model.total_gas[t] >= \
            startup_gas_rate * gunits * (1 + sys_use) * possible_starts + \
            min_running * gunits * (1 + sys_use) * model.plant_running[t]
model.gas_mins = Constraint(model.T, rule=gas_mins)

# constrain the gas used for power to zero if not running, or the period max
def max_flow(model, t):
    return model.gas_for_power[t] <= model.plant_running[t] * gunits * gnet / 12 * hr
model.running_max_gas = Constraint(model.T, rule=max_flow)

# add the overhead to running gas to capture total gas by when running
def tot_gas_for_conversion(model, t):
    return model.total_gas[t] >=  model.gas_for_power[t] * (1 + sys_use)
model.tot_gas_for_conversion = Constraint(model.T, rule=tot_gas_for_conversion)


# I don't believe the constraint below is needed.  The model will try to maximize
# gas use when profit is available, and we have already established a minimum

# def gas_volume_used(model, t):
#     'how much gas is being used?'
#     'during cold start, a small amount of gas is used'
#     'during operation, gas is consumed based on the Heat Rate'
#     if model.plant_running[t] == 1 and model.GasUnit_mw[t] == 0:
#         return model.gas_use[t] == startup_gas_rate * gunits * (1+sys_use)
#     else:
#         return model.gas_use[t] == model.GasUnit_mw[t] * hr * (1+sys_use)

# model.gas_vol = Constraint(model.T, rule=gas_volume_used)

# this isn't doing what you think, you are not controlling the range of summation to 24 time periods
# def daily_max_gas(model, t):
#     "only 9000 GJ of gas can be used in a single day"
#     min_t = model.T.first()
#     max_t = model.T.last()

#     # Check all t until the last 24 hours
#     if t <= max_t:
#         return sum(model.gas_use[i] for i in range(min_t, max_t+1)) <= model.MaxGas
#     else:
#         return Constraint.Skip

# model.max_gas_vol = Constraint(model.T, rule=daily_max_gas)

# constraint to limit consumption to max per 24hr rolling period.  Note, this is *rolling* constraint,
# if a "daily" constraint tied to particular time of day is needed, more work will need to be done
# on the index to identify the indices of interest
window_size = 6  # for testing on small data, normally would be: 24hrs * 12intervals/hr
def rolling_max(model, t):
    preceding_periods = {t_prime for t_prime in model.T if t - window_size <= t_prime < t}
    return sum(model.total_gas[t_prime] for t_prime in preceding_periods) <= model.MaxGas
eval_periods = {t for t in model.T if t >= window_size}
model.rolling_max = Constraint(eval_periods, rule=rolling_max)

# Define the income, expenses, and profit
gas_income = sum(df.loc[t, 'rrp'] * model.gas_for_power[t] / hr  for t in model.T)
gas_expenses = sum(model.total_gas[t] * gprice for t in model.T)  # removed the "vom" computation
profit = gas_income - gas_expenses
model.objective = Objective(expr=profit, sense=maximize)

# Solve the model
solver = SolverFactory('glpk')
results = solver.solve(model)
print(results)
# model.display()
# model.pprint()

cols = []
cols.append(pd.Series(model.P.extract_values(), name='energy price'))
cols.append(pd.Series(model.total_gas.extract_values(), name='total gas'))
cols.append(pd.Series(model.gas_for_power.extract_values(), name='converted gas'))
df_results = pd.concat(cols, axis=1)

fig, ax1 = plt.subplots()
width = 0.25
ax1.bar(np.array(df.index)-width, df_results['energy price'], width, color='g', label='price of power')
ax1.set_ylabel('price')
ax2 = ax1.twinx()
ax2.set_ylabel('gas')
ax2.bar(df.index, df_results['total gas'], width, color='b', label='tot gas')
ax2.bar(np.array(df.index)+width, df_results['converted gas'], width, color='xkcd:orange', label='converted gas')
fig.legend()
fig.tight_layout()
plt.show()
Schell answered 15/3, 2022 at 23:20 Comment(1)
This is incredible, it makes so much more sense now. thank you so much for your effort and genius! Much appreciatedPeptide

© 2022 - 2024 — McMap. All rights reserved.