8.2. Electricity Market Optimization#

Home Activity

You are expected to read this entire notebook before class.

8.2.1. Learning Objectives#

After studying this notebook, completing the activities, and asking questions in class, you should be able to:

  • Solve more complex optimization problems using pyomo.

  • Create mathematical models on paper.

  • Answer questions using/interpreting the pyomo model results.

'''
This cell installs Pyomo and Ipopt on Google Colab. To run this notebook
locally (e.g., with anaconda), you first need to install Pyomo and Ipopt.
'''

import sys
if "google.colab" in sys.modules:
    !wget "https://raw.githubusercontent.com/ndcbe/CBE60499/main/notebooks/helper.py"
    import helper
    helper.install_idaes()
    helper.install_ipopt()
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
from pyomo.environ import *

8.2.2. Optimal Operation of Battery Energy Storage#

Grid-scale battery energy storage systems (BESS) are expected to play a critical role in supporting wide-spread adoption of renewable electricity. In an effort to future proof their electric grid, California has mandated over 1 GW of BESS power capacity be brought online by 2020. For context, the peak electricity demand in California for 2021 was 44 GW.

For policy-makers, technology developers, and investors, there is a critical need to understand the true value of energy storage systems. In California and many regions throughout the United States, electricity is purchased and sold in a wholesale market with time-varying prices (units of $ / MWh). In principle, a smart battery operator wants to buy low (charge) and sell high (discharge). This is known as energy arbitrage.

8.2.3. Visualize Price Data#

The text file Prices_DAM_ALTA2G_7_B1.csv contains an entire year of wholesale energy prices for Chino, CA. Let’s import and inspect the data using Pandas. Our text file contains only one column and no header. We will manually specify “Price” as the name for the single column.

Class Activity

Run the code below.

data = pd.read_csv('https://raw.githubusercontent.com/ndcbe/data-and-computing/main/notebooks/data/Prices_DAM_ALTA2G_7_B1.csv',names=['Price'])
data.head()
Price
0 36.757
1 34.924
2 33.389
3 32.035
4 33.694

Let’s compute some summary statistics.

data.describe()
Price
count 8760.000000
mean 32.516994
std 9.723477
min -2.128700
25% 26.510000
50% 30.797500
75% 37.544750
max 116.340000

Let’s make a histogram.

plt.hist(data['Price'])
plt.xlabel("Price [$/MWh]")
plt.ylabel("Frequency")
plt.show()
../../_images/ac945d2a5f1e38450b1944f5bbaf8d73d096c8efc6e6c2ac27adc4495944ca60.png

Finally, let’s plot the prices for the first three days in the data set.

# determine number of hours to plot
nT = 3*24

# determine hour we should start counting
# 0 means start counting at the first hour, i.e., midnight on January 1, 2015
t = 0 + np.arange(nT+1)
price_data = data["Price"][t]

# Make plot.
plt.figure()
plt.step(t,price_data,'m.-',where='post')
plt.xlabel('Time (hr)')
plt.ylabel('Energy Price ($/MWh)')
plt.xticks(range(0,nT+1,12))
plt.show()
../../_images/ba97a63649e21e45a65ce410625e0673d322523a9394cfe418138c905640fff1.png

8.2.4. Create Mathematical Model#

For simplicity, assume a battery energy storage system is price-taker, i.e., they are subject to the market price but their actions do not influence the market price. During each hour \(t\), the battery operator decided to either charge (buy energy) or discharge (sell energy) at rates \(c_t\) and \(d_t\) (units: MW), respectively, subject to the market price \(p_t\) (units: $/MWh).

Assume the battery has a round trip efficiency of \(\eta = 88\%\). Let \(E_t\) represent the state-of-charge at time \(t\) (units: MW).

Class Activity

Using the picture above, sketch the battery system. Label \(d_t\), \(c_t\), \(E_t\), and \(p_t\) on your sketch. Verify the units are consistent.

We can now write a constrained optimization problem to compute the optimal market participation strategy (when to buy and sell).

\[\begin{align*} \max_{E,d,c} \quad & \psi := \sum_{t = 1}^{N} p_{t} (d_{t} - c_{t}) \\ \mathrm{s.t.} \quad & E_{t} = E_{t-1} + c_{t} \sqrt{\eta} - \frac{d_{t}}{\sqrt{\eta}}, ~~ \forall ~ t \in \{1,...,N\} \\ & 0 \leq c_{t} \leq c_{max}, ~~ 0 \leq d_{t} \leq d_{max}, \nonumber \\ & 0 \leq E_{t} \leq E_{max}, ~~\forall ~ t \in \{1,...,N\} \end{align*}\]

Class Activity

Discuss the optimization problem above with a partner. Then, write a few sentences to explain each equation.

8.2.5. Define Pyomo Model#

We can now write the optimization problem in Pyomo. We will create a set TIME to write the model compactly. For a review on creating concrete models, refer to the previous notebook (01-Pyomo-Basics).

Class Activity

Finish the Pyomo model below.

# define a function to build model
def build_model(price,e0 = 0):
    '''
    Create optimization model for battery operation

    Inputs:
        price: Pandas DataFrame with energy price timeseries
        e0: initial value for energy storage level
    
    Output:
        model: Pyomo optimization model
    '''
    
    # Create a concrete Pyomo model.
    model = ConcreteModel()

    ## Define Sets

    # Number of timesteps in planning horizon
    model.TIME = Set(initialize = price.index)

    ## Define Parameters

    # Square root of round trip efficiency
    model.sqrteta = Param(initialize = sqrt(0.88))

    # Energy in battery at t=0
    model.E0 = Param(initialize = e0, mutable=True)

    # Charging rate [MW]
    model.c = Var(model.TIME, initialize = 0.0, bounds=(0, 1))

    # Add your solution here
    
    ## Define constraints
    
    # Define Energy Balance constraints. [MWh] = [MW]*[1 hr]
    # Note: this model assumes 1-hour timestep in price data and control actions.
    def EnergyBalance(model,t):
        # First timestep
        if t == 0 :
            return model.E[t] == model.E0 + model.c[t]*model.sqrteta-model.d[t]/model.sqrteta 
        
        # Subsequent timesteps
        else :
            # Add your solution here
    
    model.EnergyBalance_Con = Constraint(model.TIME, rule = EnergyBalance)
    
    ## Define the objective function (profit)
    # Receding horizon
    def objfun(model):
        return  sum((-model.c[t] + model.d[t]) * price[t] for t in model.TIME)
    model.OBJ = Objective(rule = objfun, sense = maximize)
    
    return model

8.2.6. Solve Optimization Model#

We can now create an instance of our Pyomo model. Notice the function build_model requires we pass in a Pandas DataFrame with the price data. Let’s try the first day only.

# Build the model
instance = build_model(data["Price"][0:24],0.0)

# Specify the solver
solver = SolverFactory('ipopt')

# Solve the model
results = solver.solve(instance, tee = True)
Ipopt 3.11.1: 

******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit http://projects.coin-or.org/Ipopt
******************************************************************************

NOTE: You are using Ipopt by default with the MUMPS linear solver.
      Other linear solvers might be more efficient (see Ipopt documentation).


This is Ipopt version 3.11.1, running with linear solver mumps.

Number of nonzeros in equality constraint Jacobian...:       95
Number of nonzeros in inequality constraint Jacobian.:        0
Number of nonzeros in Lagrangian Hessian.............:        0

Total number of variables............................:       72
                     variables with only lower bounds:        0
                variables with lower and upper bounds:       72
                     variables with only upper bounds:        0
Total number of equality constraints.................:       24
Total number of inequality constraints...............:        0
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0

iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
   0 2.1094237e-015 1.13e-002 2.99e+001  -1.0 0.00e+000    -  0.00e+000 0.00e+000   0
   1 -1.4124684e-001 1.07e-002 2.72e+001  -1.0 2.03e-001    -  8.78e-002 4.88e-002f  1
   2 -2.6373063e+000 8.29e-003 2.74e+001  -1.0 8.21e-001    -  5.78e-002 2.27e-001f  1
   3 -2.2360880e+001 6.20e-003 2.78e+001  -1.0 3.20e+000    -  3.56e-002 2.52e-001f  1
   4 -3.8049600e+001 5.73e-003 2.51e+001  -1.0 8.23e+000    -  9.39e-002 7.63e-002f  1
   5 -5.9077018e+001 4.87e-003 2.34e+001  -1.0 6.97e+000    -  8.18e-002 1.50e-001f  1
   6 -7.9367583e+001 3.64e-003 2.18e+001  -1.0 4.22e+000    -  9.20e-002 2.52e-001f  1
   7 -8.5414120e+001 3.02e-003 1.71e+001  -1.0 2.06e+000    -  2.10e-001 1.72e-001f  1
   8 -8.9903789e+001 1.64e-003 1.17e+001  -1.0 1.23e+000    -  3.29e-001 4.57e-001f  1
   9 -9.2816651e+001 9.05e-004 3.65e+000  -1.0 1.25e+000    -  6.67e-001 4.48e-001f  1
iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
  10 -9.3848256e+001 4.09e-004 1.17e+000  -1.0 9.08e-001    -  6.61e-001 5.48e-001f  1
  11 -9.3998203e+001 3.54e-005 6.77e-002  -1.0 7.33e-001    -  1.00e+000 9.13e-001f  1
  12 -9.8037848e+001 4.44e-016 2.55e-003  -1.7 1.37e-001    -  9.88e-001 1.00e+000f  1
  13 -9.9097566e+001 4.44e-016 9.77e-015  -2.5 5.13e-002    -  1.00e+000 1.00e+000f  1
  14 -9.9251854e+001 4.44e-016 2.64e-003  -3.8 2.84e-002    -  9.49e-001 1.00e+000f  1
  15 -9.9252231e+001 4.44e-016 7.61e-015  -3.8 6.38e-004    -  1.00e+000 1.00e+000f  1
  16 -9.9260699e+001 4.44e-016 1.02e-014  -5.7 6.12e-004    -  1.00e+000 1.00e+000f  1
  17 -9.9260804e+001 4.44e-016 1.55e-005  -8.6 8.42e-006    -  1.00e+000 9.97e-001f  1
  18 -9.9260804e+001 8.88e-016 2.84e-001  -8.6 2.56e-008    -  8.71e-001 1.00e+000f  1
  19 -9.9260804e+001 8.88e-016 7.58e-015  -8.6 2.64e-009    -  1.00e+000 1.00e+000h  1

Number of Iterations....: 19

                                   (scaled)                 (unscaled)
Objective...............: -9.9260804110396435e+001  -9.9260804110396435e+001
Dual infeasibility......:  7.5766950348697468e-015   7.5766950348697468e-015
Constraint violation....:  8.8817841970012523e-016   8.8817841970012523e-016
Complementarity.........:  3.0241425605247924e-009   3.0241425605247924e-009
Overall NLP error.......:  3.0241425605247924e-009   3.0241425605247924e-009


Number of objective function evaluations             = 20
Number of objective gradient evaluations             = 20
Number of equality constraint evaluations            = 20
Number of inequality constraint evaluations          = 0
Number of equality constraint Jacobian evaluations   = 20
Number of inequality constraint Jacobian evaluations = 0
Number of Lagrangian Hessian evaluations             = 19
Total CPU secs in IPOPT (w/o function evaluations)   =      0.056
Total CPU secs in NLP function evaluations           =      0.000

EXIT: Optimal Solution Found.

8.2.7. Extract Solution from Pyomo#

Excellent. Ipopt terminated with the message Optimal Solution Found. Let’s inspect the answer.

Class Activity

Run the code below.

# Declare empty lists
c_control = []
d_control = []
E_control = []
time = []

# Loop over elements of TIME set.
for i in instance.TIME:    
    # Record the time
    time.append(value(i))
    
    # Use value( ) function to extract the solution for each variable and append to the results lists
    c_control.append(value(instance.c[i]))
    
    # Adding negative sign to discharge for plotting
    d_control.append(-value(instance.d[i]))
    E_control.append(value(instance.E[i]))

8.2.8. Plot Results#

Now let’s plot the optimal charge and discharge profile.

# Plot the state of charge (E)
plt.figure()
plt.plot(time,E_control,'b.-')
plt.xlabel('Time (hr)')
plt.ylabel('SOC (MWh)')
plt.xticks(range(0,24,3))
plt.show()

# Plot the charging and discharging rates 
# we can do a step/stair plot as the control values are constant across each hr
plt.figure()
plt.step(time,c_control,'r.-',where="post")
plt.step(time,d_control,'g.-',where="post")
plt.xlabel('Time (hr)')
plt.ylabel('Power from Grid (MW)')
plt.xticks(range(0,24,3))
plt.show()
../../_images/6c777f1bd94b20a0dedc4e00e2bf322a486e6661e124f4152f44f37eb3b424b1.png ../../_images/33221406be4bca8dec0b9644613afbfc6a62156dbf916fc07b2fea052f38543c.png

8.2.9. How much money can a 4 MWh battery make in a year?#

Class Activity

Copy the code from the cell below the heading Solve Optimization Model to the cell below. Then modify to calculate the revenue for an entire year. Hint: Do NOT modify the function create_model.

# Add your solution here