# Announcements
- Notebook from Lecture 13 updated on D2L
- Please familiarize ourself with the term projects, and sign up for your (preliminary) choice using [this form](https://forms.gle/ByLLpsthrpjCcxG89). _You may revise your choice, but I'd recommend settling on a choice well before Thanksgiving._
- Recommended reading on ODEs: [Lecture notes by Prof. Hjorth-Jensen (University of Oslo)](https://www.asc.ohio-state.edu/physics/ntg/6810/readings/hjorth-jensen_notes2013_08.pdf)
- Problem Set 4 will be posted on D2L by 9/28.
- __Outlook__: Week of 9/28 will cover adaptive step size and symplectic ODE integrators, after that we'll start with PDEs
- Conference for Undergraduate Women in Physics: online event in 2021, [applications accepted until 10/25](https://www.aps.org/programs/women/cuwip/)

## Summary: Solving systems of higher-order ODEs
### Step 1: Reduction to system of first-order ODEs
Any ordinary differential equation with a highest derivative
$d^ng/dy^n$ can always be expressed as a set of $n$ first-order
differential equations by introducing a set of auxiliary variables.

If we have an $n^{th}$-order ODE and we can solve for the $n^{th}$ derivative
$
  y^{(n)} = g(t, y, y', y'',\dots, y^{(n-1)})
$
and convert it to a system of $n$ first-order ODEs by defining auxiliary
variables $v_i$ as
\begin{align}
v_0 &= y\\
v_1 &= y'\\
& \vdots\\
v_{n-1} &= y^{(n-1)}\quad\quad\quad\quad\quad\quad
\end{align}
The resulting system of first-order ODEs is then
\begin{align}
  v'_0 &= v_1\\
  v'_1 &= v_2\\
  & \vdots\\
  v'_{n-1} &= g(t, v_0, v_1, v_2,\dots,v_{n-1})
\end{align}

__Example from Lecture 12: van der Pol oscillator__

$$ \frac{d^2 x}{dt^2} - a (1-x^2) \frac{dx}{dt} + x = 0 $$
We turned this into a pair of first-order ODEs by defining an auxiliary function $v(t) = dx/dt$ and writing the system as
\begin{align}
\frac{dx}{dt} &= v\\
\frac{dv}{dt} &= a (1-x^2) v - x
\end{align}

_Note that there are only functions (and the independent variable) on the RHS; all "differentials" are on the LHS._

### Step 2: Solve system of first-order ODEs
We can write the system of first-order ODEs in vector form as $\textbf{v}' = \textbf{g}(t,
\textbf{v})$, with $g_i(t, \textbf{v}) = v_i$ for $i<n-1$, and $g_{n-1}(t,
\textbf{v}) = g(t,v_0, v_1, v_2,\dots,v_{n-1})$.

Integration then proceeds (using your favorite integrator) by
taking a single step in all $n$ equations individually, taking care to
distinguish between the old and the new values of all variables.

See notebooks from Lectures 12 and 13 for worked examples.

# Exercise: The damped Pendulum
adapted from lecture notes by Phil Pinto, and notebooks by Jonas Tjemsland, Eilif Sommer Øyre and Jon Andreas Støvneng.
## The simple Pendulum
We will first discuss the *simple pendulum* which is a simple and idealised model of the real pendulum. It consists of a point mass $m$ suspended by an unstretchable, massless string (or rod) of length $l$. The pendulum only moves in one plane and there is no friction. Let $\theta$ denote the angle between the vertical axis and the string, such that $\theta=0$ at equilibrium. Note that the position of the pendulum can be specified using only $\theta$. If we pull the mass to one side and let it go, it will start to to oscillate around the equilibrium, similar to a wrecking ball.

There are two forces acting on the mass: the gravitaional force $\mathbf{F} = m\mathbf{g}$ and the pull from the string $\mathbf{S}$. The forces can be decomposed into a direction along the string (radial direction, $\hat r$) and a direction along the path of the point mass (azimuthal direction, $\hat \theta$). Since the string is assumed to be unstretchable, only the gravitational pull in the azimuthal direction, $\mathbf F_\theta$, will contribute to the motion of the pendulum. The gravitational force in the radial direction, $\mathbf F_r$, is cancelled by the pull from the string: $\mathbf F_r = - \mathbf{S}$. Henceis clear that
\begin{equation}
F_\theta = -mg\sin\theta.
\end{equation}

We can now use Newton's second law, $\mathbf{F}=m\mathbf{a}$, to find an differential equation describing the motion of the pendulum. Let $t$ denote the time. The acceleration of the point mass can be expressed by
\begin{equation}
\mathbf{a} = \frac{\mathrm{d}^2\theta}{\mathrm{d} t^2}\;\hat \theta = l\ddot \theta\;\hat \theta.
\end{equation}
The motion of the pendulum is thus described by the equation
\begin{equation}\label{simplependODE}\tag{1}
\ddot \theta = \frac{\mathrm{d}^2\theta}{\mathrm{d} t^2} = -\frac{g}{l}\sin\theta = -\omega_0^2\sin\theta.
\end{equation}
where $\omega_0 = \sqrt{g/l}$ is the _natural frequency_ of the pendulum.

__For the rest of this
discussion, we will scale the units of time so that $\omega_0=1$__.

### Analytical Approximation
Equation (\ref{simplependODE}) cannot be solved analytically. However, note that $\sin\theta\approx \theta$ for $\theta \ll 1$. Thus, for small angles, the motion can be approximated by
\begin{equation}
\ddot \theta \approx -\omega_0\theta,
\end{equation}
which has the simple solution
\begin{equation}
\theta=\theta_0\cos(\omega_0 t),
\end{equation}
where $\theta_0$ is the initial posision at time $t=0$. This is known as a harmonic oscillation. The period of the oscillation is $T=2\pi/\omega_0=2\pi\sqrt{l/g}$.

An implementation of the analytic approximation `simple_pendulum_approx` is provided below.

### Numerical Solution
 Introduce the angular velocity $\omega = \frac{d\theta}{d t}$ and rewrite Equation (\ref{simplependODE}) into a system of two coupled first-order ODEs, and complete the function `simple_pendulum_RHS` that provides an expression for the right hand side of this system of linear ODES.

 Then set up our function `odeSolve(t0, y0, tmax, h, RHS, method, *P)` (copied below) to solve this system of ODEs. Plot the numerical solution (angle) for the pedulum starting _at rest_ at two different initial angles $\theta_0=\{15^\circ, 60^\circ\}=\{\pi/6, \pi/3\}$ over the time interval $[0,10]$ and compare with the analytical approximation.

### Solution
\begin{equation}
  \begin{split}
    \frac{d\theta}{dt} &= \omega\\
    \frac{d\omega}{dt} &= -\sin(\theta)
  \end{split}
\end{equation}

```python
#import standard libraries

import numpy as np
import matplotlib.pyplot as plt
def simple_pendulum_approx(t, theta0):
    """ Evaluates the analytical approximation. """
    return theta0*np.cos(t)

# YOUR TURN!
def simple_pendulum_RHS(t,y,*P):
    """ Return the right hand side of the
    ordinary differential equation describing
    a simple pendulum.
    We store our function as the array y=[theta,omega]
    """
    return np.array([y[1],-np.sin(y[0])])

```

```python
# code copied from Lecture 12/13
# you don't need to change anything in this cell
def RK4_step(t, y, h, g, *P):
    """
    Implements a single step of a fourth-order, explicit Runge-Kutta scheme
    """
    thalf = t + 0.5*h
    k1 = h * g(t, y, *P)
    k2 = h * g(thalf, y + 0.5*k1, *P)
    k3 = h * g(thalf, y + 0.5*k2, *P)
    k4 = h * g(t + h, y + k3, *P)
    return y + (k1 + 2*k2 + 2*k3 + k4)/6
def odeSolve(t0, y0, tmax, h, RHS, method, *P):
    """
    ODE driver with constant step-size, allowing systems of ODE's
    """
    # make array of times and find length of array
    t = np.arange(t0,tmax+h,h)
    ntimes,  = t.shape

    # find out if we are solving a scalar ODE or a system of ODEs, and allocate space accordingly

    if type(y0) in [int, float]:  # check if primitive type -- means only one eqn
        neqn = 1
        y = np.zeros( ntimes )
    else:                         # otherwise assume a numpy array -- a system of more than one eqn
        neqn, = y0.shape
        y = np.zeros( (ntimes, neqn) )

    # set first element of solution to initial conditions (possibly a vector)
    y[0] = y0

    # march on...
    for i in range(0,ntimes-1):
        y[i+1] = method(t[i], y[i], h, RHS, *P)

    return t,y
```

```python
#two different initial conditions
theta01 = np.pi/12
theta02 = np.pi/3
t0 = 0
tmax = 10
#choose a time step
h = 0.1
#obtain t, y1 for the starting point theta01
t,y1 = odeSolve(t0,np.array([theta01,0]),tmax,h,simple_pendulum_RHS,RK4_step,0)

#obtain t, y2 for the starting point theta01
t,y2 = odeSolve(t0,np.array([theta02,0]),tmax,h,simple_pendulum_RHS,RK4_step,0)

```

```python
plt.title("Angular position")
#plot t, theta obtained numerically from initial condition theta01
plt.plot(t, y1[:,0], "m", label=r"$\theta_0=%.0f^\circ$"%(theta01*180/np.pi))
#plot t, approximated theta for initial condition theta01
plt.plot(t, simple_pendulum_approx(t, theta01), "m--", label=r"Approximation")
#plot t, theta obtained numerically from initial condition theta02
plt.plot(t, y2[:,0], "g", label=r"$\theta_0=%.0f^\circ$"%(theta02*180/np.pi))
#plot t, approximated theta for initial condition theta02
plt.plot(t, simple_pendulum_approx(t, theta02), "g--", label=r"Approximation")
plt.xlabel(r"$t$, [s]")
plt.ylabel(r"$\theta(t)$, [rad]")
plt.legend()
plt.show()

```

### Conservation of Energy

The total mechanical energy,
\begin{equation}
E = T + U =  \frac{1}{2} m l^2 \dot\theta^2 +g m l(1- \cos(\theta)),
\end{equation}
should be conserved. It thus serves as an excellent way to check if the time step used above was sufficiently small.
In our rescaled units,
\begin{equation}
\tilde{E} = \tilde{T} + \tilde{U} = \frac{1}{2}\dot \theta^2 +(1-\cos(\theta))
\end{equation}
 Let's plot the kinetic, potential and total energy (per unit mass) for the largest initial angle. If total energy is conserved, increase the step size until it isn't; if total energy is not conserved, decrease the step size until it is.

```python
def get_U(y):
    """ Computes the potential energy from coordinate vector [y]. """
    return 1- np.cos(y[:,0])

def get_T(y):
    """ Computes the kinetic energy from coordinate vector [y]. """
    return 0.5*y[:,1]**2
```

```python
plt.figure()
plt.title(r"Mechanical energy, $\theta_0=%.0f^\circ$"%(theta02*180/np.pi))
plt.plot(t, get_U(y2), label=r"Potential energy")
plt.plot(t, get_T(y2), label=r"Kinetic energy")
plt.plot(t, get_U(y2) + get_T(y2), label=r"Total energy")
plt.xlabel(r"$t$")
plt.ylabel(r"$E$")
plt.legend(loc=1)
plt.show()
```

## The damped Pendulum

A real pendulum cannot oscillate forever. Due to friction, the pendulum will eventually slow down and stop. At low velocities, the air resistance is proportional to the velocity, $f = -b\dot \theta$ . The differential equation describing this pendulum is described by
\begin{equation}
\ddot \theta = -\underbrace{\frac{b}{m l}}_{Q}\dot \theta - \omega_0^2\sin\theta,
\label{diff_damped}\tag{2}
\end{equation}
where we have introduced another proportionality constant $Q$.
As before, the equation has an analytical solution for $\theta \ll 1$, which you can obtain using standard techniques.

 Recast Equation (\ref{diff_damped}) into a system of two coupled first-order ODEs, and modify the function `simple_pendulum_RHS` from above into `damped_pendulum_RHS`, an expression for the right hand side of this system of linear ODES.
 Choose some value for $Q$, solve the damped pendulum for $\theta_0 = 60^\circ$, and plot the result.

 What happens to conservation of energy?

### Solution
\begin{equation}
  \begin{split}
    \frac{d\omega}{dt} &= -Q\omega-\sin(\theta) \\
    \frac{d\theta}{dt} &= \omega
  \end{split}
\end{equation}

```python
# YOUR TURN!
def damped_pendulum_RHS(t,y,*P):
    """ Return the right hand side of the
    ordinary differential equation describing
    a simple pendulum.
    We store our function as the array y=[theta,omega]
    """
    #unpack parameters
    Q,= P
    return np.array([y[1],-np.sin(y[0])-Q*y[1]])

```

```python
#initial conditions
theta02 = np.pi/3
t0 = 0
tmax = 10
#choose a time step
h = 0.1
Q1 = .5
Q2 = 1.0

#obtain t, y2_damped for the starting point theta02, Q value Q1
t,y2_damped = odeSolve(t0,np.array([theta02,0]),tmax,h,damped_pendulum_RHS,RK4_step,Q1)
plt.plot(t, y2_damped[:,0], "g", label=r"$Q=%.0f^\circ$"%(Q1))
#obtain t, y2_damped for the starting point theta02, Q value Q2
t,y2_damped = odeSolve(t0,np.array([theta02,0]),tmax,h,damped_pendulum_RHS,RK4_step,Q2)
plt.plot(t, y2_damped[:,0], "g", linestyle =":",label=r"$Q=%.0f^\circ$"%(Q2))
plt.xlabel(r"$t$, [s]")
plt.ylabel(r"$\theta(t)$, [rad]")
plt.legend()
plt.show()
plt.show()
```

```python
plt.figure()
plt.title(r"Mechanical energy, $\theta_0=%.0f^\circ$"%(theta02*180/np.pi))
plt.plot(t, get_U(y2_damped), label=r"Potential energy")
plt.plot(t, get_T(y2_damped), label=r"Kinetic energy")
plt.plot(t, get_U(y2_damped) + get_T(y2_damped), label=r"Total energy")
plt.xlabel(r"$t$")
plt.ylabel(r"$E$")
plt.legend(loc=1)
plt.show()
```

Over time, the energy of the pendulum system gets dissipated!

```python

```


# Unit Commitment

_**[Power Systems Optimization](https://github.com/east-winds/power-systems-optimization)**_

_by Michael R. Davidson and Jesse D. Jenkins (last updated: August 31, 2021)_

This notebook will build on the economic dispatch (ED) model by introducing binary startup (called "commitment") decisions, constraints, and costs of thermal generators. Similar to ED, it minimizes the short-run production costs of meeting electricity demand, but these additions are necessary for operating systems with large amounts of (inflexible) thermal generation. They also entail significant computational trade-offs.

****

We build up the model in several stages. We first start with the simplest model that incorporates unit commitment decisions in order to understand the logic of three-variable commitment formulations.

Next, we introduce moderate complexity through the addition of ramp constraints—which we need to be modified from the ED to account for startups and shutdowns.

Finally, we model a more realistic unit commitment that includes reserves—an important feature of day-ahead scheduling in power systems.

## Introduction to unit commitment

Engineering considerations severely limit the possible output ranges of power plants. System operators need to be aware of these limitations when scheduling generation to meet demand. Thermal power plants, in particular, due to their complex designs, thermodynamic cycles, and material properties, can be particularly challenging. In practice, due to the times involved in bringing these power plants online, much of this scheduling is done day-ahead, which gives rise to the need for a day-ahead market.

**Unit commitment** (UC) is the problem of minimizing short-run costs of production inclusive of production and startup costs, in order to meet a given demand and considering relevant engineering constraints of the generators. It is built on the ED model in that it typically considers all of the same variables in addition to the new startup-related variables and constraints—hence, it is also sometimes called unit commitment and economic dispatch (UCED). This notebook, for simplicity, only considers a single-bus case, while typical formulations usually include a simplified network representation.

## Simple unit commitment

We start with the simplest case that incorporates unit commitment, building on the `economic_dispatch_multi` problem of the [Economic Dispatch notebook](04-Economic-Dispatch.ipynb).

$$
\begin{align}
\min \ & \sum_{g \in G, t \in T} VarCost_g \times GEN_{g,t} + \sum_{g \in G_{thermal}, t \in T} StartUpCost_g \times START_{g,t} \\
\end{align}
$$
$$
\begin{align}
\text{s.t.} & \\
 & \sum_{g} GEN_{g,t} = Demand_t & \forall \quad t \in T \\
 & GEN_{g,t} \leq Pmax_{g,t} & \forall \quad g \notin G_{thermal} , t \in T \\
 & GEN_{g,t} \geq Pmin_{g,t} & \forall \quad g \notin G_{thermal} , t \in T \\
 & GEN_{g,t} \leq Pmax_{g,t} \times COMMIT_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & GEN_{g,t} \geq Pmin_{g,t} \times COMMIT_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & COMMIT_{g,t} \geq \sum_{t'≥t-MinUp_g}^{t} START_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & 1-COMMIT_{g,t} \geq \sum_{t'≥t-MinDown_g}^{t} SHUT_{g,t} &\forall \quad g \in G_{thermal} , t \in T \\
  & COMMIT_{g,t+1} - COMMIT_{g,t} =&\\
 & \quad START_{g,t+1} - SHUT_{g,t+1} &\forall \quad G_{thermal} \in G , t = 1..T-1
\end{align}
$$

The **decision variables** in the above problem:

- $GEN_{g}$, generation (in MW) produced by each generator, $g$
- $START_{g,t}$, startup decision (binary) of thermal generator $g$ at time $t$
- $SHUT_{g,t}$, shutdown decision (binary) of thermal generator $g$ at time $t$
- $COMMIT_{g,t}$, commitment status (binary) of generator $g$ at time $t$

The **parameters** are:

- $Pmin_g$, the minimum operating bounds for generator $g$ (based on engineering or natural resource constraints)
- $Pmax_g$, the maximum operating bounds for generator $g$ (based on engineering or natural resource constraints)
- $Demand$, the demand (in MW)
- $VarCost_g = VarOM_g + HeatRate_g \times FuelCost_g$, the variable cost of generator $g$
- $StartUpCost_g$, the startup cost of generator $g$
- $MinUp_g$, the minimum up time of generator $g$, or the minimum time after start-up before a unit can shut down
- $MinDown_g$, the minimum down time of generator $g$, or the minimum time after shut-down before a unit can start again

In addition, we introduce a few different sets:

- $G$, the set of all generators
- $G_{thermal} \subset G$, the subset of thermal generators for which commitment is necessary
- $T$, the set of all time periods over which we are optimizing commitment and dispatch decisions

Finally, the **three-variable commitment equations** capture the basic logic of commitment:

- Units incur costs when they startup (not when they shutdown)
- Units must stay on (and off) for a minimum period of time—in lieu of explicitly enforcing a startup trajectory
- Some summations (simplified here) will need to be modified near the beginning of the time period

There are some further resources at the bottom for alternative and/or more complex formulations of the UC.

Now, let's implement UC.

### 1. Load packages

```julia
using JuMP, GLPK
using Plots; plotly();
using VegaLite  # to make some nice plots
using DataFrames, CSV, PrettyTables
ENV["COLUMNS"]=120; # Set so all columns of DataFrames and Matrices are displayed
```

    ┌ Info: For saving to png with the Plotly backend PlotlyBase has to be installed.
    └ @ Plots /Users/michd/.julia/packages/Plots/4UbNP/src/backends.jl:435

### 2. Load and format data

We will use data loosely based on San Diego Gas and Electric (SDG&E, via the [PowerGenome](https://github.com/gschivley/PowerGenome) data platform) including a few neighboring generators and adjustments to make the problem easy to solve, consisting of:

- 33 generators (we added a few more to ensure we can provide enough reserves)
- estimated hourly demand for 2020 (net load at the transmission substation level after subtracting 600MW of behind-the-meter solar from original demand)
- variable generation capacity factors
- estimated natural gas fuel costs

In order to demonstrate the impacts of unit commitment, we will keep our high solar sensitivity case from the [Economic Dispatch notebook](04-Economic-Dispatch.ipynb) (with 3,500 MW of solar PV) to produce large variations in net load (demand less available renewable supply) required to meet demand (similar to [California's infamous "Duck Curve"](https://www.caiso.com/Documents/FlexibleResourcesHelpRenewables_FastFacts.pdf)).

```julia
datadir = joinpath("uc_data")
gen_info = CSV.read(joinpath(datadir,"Generators_data.csv"), DataFrame);
fuels = CSV.read(joinpath(datadir,"Fuels_data.csv"), DataFrame);
loads = CSV.read(joinpath(datadir,"Demand.csv"), DataFrame);
gen_variable = CSV.read(joinpath(datadir,"Generators_variability.csv"), DataFrame);

# Rename all columns to lowercase (by convention)
for f in [gen_info, fuels, loads, gen_variable]
    rename!(f,lowercase.(names(f)))
end
```

**Construct generator dataframe**

```julia
# Keep columns relevant to our UC model
select!(gen_info, 1:26) # columns 1:26
gen_df = outerjoin(gen_info,  fuels, on = :fuel) # load in fuel costs and add to data frame
rename!(gen_df, :cost_per_mmbtu => :fuel_cost)   # rename column for fuel cost
gen_df.fuel_cost[ismissing.(gen_df[:,:fuel_cost])] .= 0

# create "is_variable" column to indicate if this is a variable generation source (e.g. wind, solar):
gen_df[!, :is_variable] .= false
gen_df[in(["onshore_wind_turbine","small_hydroelectric","solar_photovoltaic"]).(gen_df.resource),
    :is_variable] .= true;

# create full name of generator (including geographic location and cluster number)
#  for use with variable generation dataframe
gen_df.gen_full = lowercase.(gen_df.region .* "_" .* gen_df.resource .* "_" .* string.(gen_df.cluster) .* ".0");

# remove generators with no capacity (e.g. new build options that we'd use if this was capacity expansion problem)
gen_df = gen_df[gen_df.existing_cap_mw .> 0,:];
```

**Modify load and variable generation dataframes**

```julia
# 1. Convert from GMT to GMT-8
gen_variable.hour = mod.(gen_variable.hour .- 9, 8760) .+ 1
sort!(gen_variable, :hour)
loads.hour = mod.(loads.hour .- 9, 8760) .+ 1
sort!(loads, :hour);

# 2. Convert from "wide" to "long" format
gen_variable_long = stack(gen_variable,
                        Not(:hour),
                        variable_name=:gen_full,
                        value_name=:cf);
# Now we have a "long" dataframe
```

### 3. Create solver function

As a utility function, since we do this alot, we'll create a function to convert JuMP AxisArray outputs with two indexes to DataFrames.

```julia
#=
Function to convert JuMP outputs (technically, AxisArrays) with two-indexes to a dataframe
Inputs:
    var -- JuMP AxisArray (e.g., value.(GEN))
Reference: https://jump.dev/JuMP.jl/v0.19/containers/
=#
function value_to_df_2dim(var)
    solution = DataFrame(var.data, :auto)
    ax1 = var.axes[1]
    ax2 = var.axes[2]
    cols = names(solution)
    insertcols!(solution, 1, :r_id => ax1)
    solution = stack(solution, Not(:r_id), variable_name=:hour)
    solution.hour = foldl(replace, [cols[i] => ax2[i] for i in 1:length(ax2)], init=solution.hour)
    rename!(solution, :value => :gen)
    solution.hour = convert.(Int64,solution.hour)
    return solution
end
```

    value_to_df_2dim (generic function with 1 method)

Then we'll create a function to create and solve a simple unit commitment problem with specified input data.

```julia
#=
Function to solve simple unit commitment problem (commitment equations)
Inputs:
    gen_df -- dataframe with generator info
    loads  -- load by time
    gen_variable -- capacity factors of variable generators (in "long" format)
=#
function unit_commitment_simple(gen_df, loads, gen_variable)
    UC = Model(GLPK.Optimizer)

    # We reduce the MIP gap tolerance threshold here to increase tractability
    # Here we set it to a 1% gap, meaning that we will terminate once we have
    # a feasible integer solution guaranteed to be within 1% of the objective
    # function value of the optimal solution.
    # Note that GLPK's default MIP gap is 0.0, meaning that it tries to solve
    # the integer problem to optimality, which can take a LONG time for
    # any complex problem. So it is important to set this to a realistic value.
    set_optimizer_attribute(UC, "mip_gap", 0.01)

    # Define sets based on data
    # Note the creation of several different sets of generators for use in
    # different equations.
        # Thermal resources for which unit commitment constraints apply
    G_thermal = gen_df[gen_df[!,:up_time] .> 0,:r_id]
        # Non-thermal resources for which unit commitment constraints do NOT apply
    G_nonthermal = gen_df[gen_df[!,:up_time] .== 0,:r_id]
        # Variable renewable resources
    G_var = gen_df[gen_df[!,:is_variable] .== 1,:r_id]
        # Non-variable (dispatchable) resources
    G_nonvar = gen_df[gen_df[!,:is_variable] .== 0,:r_id]
        # Non-variable and non-thermal resources
    G_nt_nonvar = intersect(G_nonvar, G_nonthermal)
        # Set of all generators (above are all subsets of this)
    G = gen_df.r_id
        # All time periods (hours) over which we are optimizing
    T = loads.hour
        # A subset of time periods that excludes the last time period
    T_red = loads.hour[1:end-1]  # reduced time periods without last one

    # Generator capacity factor time series for variable generators
    gen_var_cf = innerjoin(gen_variable,
                    gen_df[gen_df.is_variable .== 1 ,
                        [:r_id, :gen_full, :existing_cap_mw]],
                    on = :gen_full)

    # Decision variables
    @variables(UC, begin
            # Continuous decision variables
        GEN[G, T]  >= 0     # generation
            # Bin = binary variables;
            # the following are all binary decisions that
            # can ONLY take the values 0 or 1
            # The presence of these discrete decisions makes this an MILP
        COMMIT[G_thermal, T], Bin # commitment status (Bin=binary)
        START[G_thermal, T], Bin  # startup decision
        SHUT[G_thermal, T], Bin   # shutdown decision
    end)

    # Objective function
        # Sum of variable costs + start-up costs for all generators and time periods
    @objective(UC, Min,
        sum( (gen_df[gen_df.r_id .== i,:heat_rate_mmbtu_per_mwh][1] * gen_df[gen_df.r_id .== i,:fuel_cost][1] +
            gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1]) * GEN[i,t]
                        for i in G_nonvar for t in T) +
        sum(gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1] * GEN[i,t]
                        for i in G_var for t in T)  +
        sum(gen_df[gen_df.r_id .== i,:start_cost_per_mw][1] *
            gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
            START[i,t]
                        for i in G_thermal for t in T)
    )

    # Demand balance constraint (supply must = demand in all time periods)
    @constraint(UC, cDemand[t in T],
        sum(GEN[i,t] for i in G) == loads[loads.hour .== t,:demand][1])

    # Capacity constraints
      # 1. thermal generators requiring commitment
    @constraint(UC, Cap_thermal_min[i in G_thermal, t in T],
        GEN[i,t] >= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])
    @constraint(UC, Cap_thermal_max[i in G_thermal, t in T],
        GEN[i,t] <= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

      # 2. non-variable generation not requiring commitment
    @constraint(UC, Cap_nt_nonvar[i in G_nt_nonvar, t in T],
        GEN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

      # 3. variable generation, accounting for hourly capacity factor
    @constraint(UC, Cap_var[i in 1:nrow(gen_var_cf)],
            GEN[gen_var_cf[i,:r_id], gen_var_cf[i,:hour] ] <=
                        gen_var_cf[i,:cf] *
                        gen_var_cf[i,:existing_cap_mw])

    # Unit commitment constraints
      # 1. Minimum up time
    @constraint(UC, Startup[i in G_thermal, t in T],
        COMMIT[i, t] >= sum(START[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:up_time][1]):t)))

      # 2. Minimum down time
    @constraint(UC, Shutdown[i in G_thermal, t in T],
        1-COMMIT[i, t] >= sum(SHUT[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:down_time][1]):t)))

      # 3. Commitment state
    @constraint(UC, CommitmentStatus[i in G_thermal, t in T_red],
        COMMIT[i,t+1] - COMMIT[i,t] == START[i,t+1] - SHUT[i,t+1])

    # Solve statement (! indicates runs in place)
    optimize!(UC)

    # Generation solution and convert to data frame
    # with our helper function defined above
    gen = value_to_df_2dim(value.(GEN))

    # Commitment status solution and convert to data frame
    commit = value_to_df_2dim(value.(COMMIT))

    # Calculate curtailment = available wind and/or solar output that
    # had to be wasted due to operating constraints
    curtail = innerjoin(gen_var_cf, gen, on = [:r_id, :hour])
    curtail.curt = curtail.cf .* curtail.existing_cap_mw - curtail.gen

    # Return the solution parameters and objective
    return (
        gen,
        commit,
        curtail,
        cost = objective_value(UC),
        status = termination_status(UC)
    )

end
```

    unit_commitment_simple (generic function with 1 method)

### 4. Solve a day's unit commitment

```julia
# A spring day
n=100
T_period = (n*24+1):((n+1)*24)

# High solar case: 3,500 MW
gen_df_sens = copy(gen_df)
gen_df_sens[gen_df_sens.resource .== "solar_photovoltaic",
    :existing_cap_mw] .= 3500

loads_multi = loads[in.(loads.hour,Ref(T_period)),:]
gen_variable_multi = gen_variable_long[in.(gen_variable_long.hour,Ref(T_period)),:]

solution = unit_commitment_simple(gen_df_sens, loads_multi, gen_variable_multi);
```

```julia
# Add in BTM solar and curtailment and plot results

sol_gen = innerjoin(solution.gen,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_gen = combine(groupby(sol_gen, [:resource, :hour]),
            :gen => sum)

# Rename generators (for plotting purposes)
sol_gen[sol_gen.resource .== "solar_photovoltaic", :resource] .= "_solar_photovoltaic"
sol_gen[sol_gen.resource .== "onshore_wind_turbine", :resource] .= "_onshore_wind_turbine"
sol_gen[sol_gen.resource .== "small_hydroelectric", :resource] .= "_small_hydroelectric"

# BTM solar
btm = DataFrame(resource = repeat(["_solar_photovoltaic_btm"]; outer=length(T_period)),
    hour = T_period,
    gen_sum = gen_variable_multi[gen_variable_multi.gen_full .== "wec_sdge_solar_photovoltaic_1.0",:cf] * 600)
append!(sol_gen, btm)

# Curtailment
curtail = combine(groupby(solution.curtail, [:hour]),
            :curt => sum)
curtail[!, :resource] .= "_curtailment"
rename!(curtail, :curt_sum => :gen_sum)
append!(sol_gen, curtail[:,[:resource, :hour, :gen_sum]])

# Rescale hours
sol_gen.hour = sol_gen.hour .- T_period[1]

sol_gen |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

Notice that the combined cycle plants flatten out during the day, but don't shut down due to the need for ramping capabilities in the late afternoon/evening, when solar output falls off and evening demand remains strong. This leads to some curtailment of solar from 11:00 - 16:00, which was not present in the Economic Dispatch model. All curtailment is lumped together as the model does not accurately distinguish between whether wind, solar or hydro is curtailed, which in practice is up to the system operator and depends on a variety of factors such as location, interconnection voltage, etc.

We can examine the commitment status of various units by examining the results in `solution.commit`.

```julia
sol_commit = innerjoin(solution.commit,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_commit = combine(groupby(sol_commit, [:resource, :hour]),
            :gen => sum)
sol_commit.hour = sol_commit.hour .- T_period[1]

sol_commit |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

Note that units shutdown during the solar period and startup for the evening peak. However, due to the commitment constraints, not all natural gas plants can be decommitted, resulting in the observed curtailment. A large number of combustion turbines turn on to meet the evening peak.

## Moderate complexity unit commitment

We expand the above unit commitment with ramp equations. These must be modified to prevent ramp violations during the startup process. In order to accommodate this, we introduce an **auxiliary variable** $GENAUX_{g,t}$ defined as the generation above the minimum output (if committed). $GENAUX_{g,t} = GEN_{g,t} = 0$ if the unit is not committed. Auxiliary variables are for convenience (we could always write the whole problem in terms of the original decision variables), but they help de-clutter the model and in general won't add too much computational penalty. The following constraints are added:

$$
\begin{align}
 & GENAUX_{g,t} = GEN_{g,t} - Pmin_{g,t}COMMIT_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & GENAUX_{g,t+1} - GENAUX_{g,t} \leq RampUp_{g} & \forall \quad g \in G_{thermal} , t = 1..T-1 \\
  & GENAUX_{g,t} - GENAUX_{g,t+1} \leq RampDn_{g} & \forall \quad g \in G_{thermal} , t = 1..T-1 \\
 & GEN_{g,t+1} - GEN_{g,t} \leq RampUp_{g} & \forall \quad g \notin G_{thermal} , t = 1..T-1 \\
  & GEN_{g,t} - GEN_{g,t+1} \leq RampDn_{g} & \forall \quad g \notin G_{thermal} , t = 1..T-1
\end{align}
$$

The creation of this auxiliary variable $GENAUX$ helps us avoid violating ramping constraints when we start up our units and their generation immediately jumps up to their minium output level or during shut-down when a unit drops from minimum output (or above) to zero.

Note you may encounter alternative formulations in the literature to address this start-up and shut-down that modify the right-hand side of the traditional ramping constraint on total generation to account for the additional step change in output during start-up or shut-down periods. The above formulation is conceptually simple and works well.

### 3. Create solver function

(We reuse steps 1 and 2 above to load packages and data.)

```julia
#=
Function to solve moderate complexity unit commitment problem (commitment and ramp equations)
Inputs:
    gen_df -- dataframe with generator info
    loads  -- load by time
    gen_variable -- capacity factors of variable generators (in "long" format)
=#
function unit_commitment_ramp(gen_df, loads, gen_variable)
    UC = Model(GLPK.Optimizer)
    set_optimizer_attribute(UC, "mip_gap", 0.01) #1% MIP gap

    # Define sets based on data
    # Note the creation of several different sets of generators for use in
    # different equations.
    G_thermal = gen_df[gen_df[!,:up_time] .> 0,:r_id]
    G_nonthermal = gen_df[gen_df[!,:up_time] .== 0,:r_id]
    G_var = gen_df[gen_df[!,:is_variable] .== 1,:r_id]
    G_nonvar = gen_df[gen_df[!,:is_variable] .== 0,:r_id]
    G_nt_nonvar = intersect(G_nonvar, G_nonthermal)
    G = gen_df.r_id
    T = loads.hour
    T_red = loads.hour[1:end-1]  # reduced time periods without last one

    # Generator capacity factor time series for variable generators
    gen_var_cf = innerjoin(gen_variable,
                    gen_df[gen_df.is_variable .== 1 ,
                        [:r_id, :gen_full, :existing_cap_mw]],
                    on = :gen_full)

    # Decision variables
    @variables(UC, begin
        GEN[G, T]  >= 0     # generation
        GENAUX[G_thermal, T] >= 0 # auxiliary generation variable
        COMMIT[G_thermal, T], Bin # commitment status (Bin=binary)
        START[G_thermal, T], Bin  # startup decision
        SHUT[G_thermal, T], Bin   # shutdown decision
    end)

    # Objective function
    @objective(UC, Min,
        sum( (gen_df[gen_df.r_id .== i,:heat_rate_mmbtu_per_mwh][1] * gen_df[gen_df.r_id .== i,:fuel_cost][1] +
            gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1]) * GEN[i,t]
                        for i in G_nonvar for t in T) +
        sum(gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1] * GEN[i,t]
                        for i in G_var for t in T) +
        sum(gen_df[gen_df.r_id .== i,:start_cost_per_mw][1] *
            gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
            START[i,t]
                        for i in G_thermal for t in T)
    )

    # Demand constraint
    @constraint(UC, cDemand[t in T],
        sum(GEN[i,t] for i in G) == loads[loads.hour .== t,:demand][1])

    # Capacity constraints (thermal generators requiring commitment)
    @constraint(UC, Cap_thermal_min[i in G_thermal, t in T],
        GEN[i,t] >= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])
    @constraint(UC, Cap_thermal_max[i in G_thermal, t in T],
        GEN[i,t] <= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

    # Capacity constraints (non-variable generation not requiring commitment)
    @constraint(UC, Cap_nt_nonvar[i in G_nt_nonvar, t in T],
        GEN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

    # Capacity constraints (variable generation)
    @constraint(UC, Cap_var[i in 1:nrow(gen_var_cf)],
            GEN[gen_var_cf[i,:r_id], gen_var_cf[i,:hour] ] <=
                        gen_var_cf[i,:cf] *
                        gen_var_cf[i,:existing_cap_mw])

    # Unit commitment constraints
    @constraint(UC, Startup[i in G_thermal, t in T],
        COMMIT[i, t] >= sum(START[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:up_time][1]):t)))

    @constraint(UC, Shutdown[i in G_thermal, t in T],
        1-COMMIT[i, t] >= sum(SHUT[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:down_time][1]):t)))

    @constraint(UC, CommitmentStatus[i in G_thermal, t in T_red],
        COMMIT[i,t+1] - COMMIT[i,t] == START[i,t+1] - SHUT[i,t+1])

    # New auxiliary variable GENAUX for generation above the minimum output level
    # for committed thermal units (only created for thermal generators)
    @constraint(UC, AuxGen[i in G_thermal, t in T],
        GENAUX[i,t] == GEN[i,t] - COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])

    # Ramp equations for thermal generators (constraining GENAUX)
    @constraint(UC, RampUp_thermal[i in G_thermal, t in T_red],
        GENAUX[i,t+1] - GENAUX[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_up_percentage][1] )

    @constraint(UC, RampDn_thermal[i in G_thermal, t in T_red],
        GENAUX[i,t] - GENAUX[i,t+1] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_dn_percentage][1] )

    # Ramp equations for non-thermal generators (constraining total generation GEN)
    @constraint(UC, RampUp_nonthermal[i in G_nonthermal, t in T_red],
        GEN[i,t+1] - GEN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_up_percentage][1] )

    @constraint(UC, RampDn[i in G, t in T_red],
        GEN[i,t] - GEN[i,t+1] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_dn_percentage][1] )

    # Solve statement (! indicates runs in place)
    optimize!(UC)

    # Generation solution
    gen = value_to_df_2dim(value.(GEN))

    # Commitment status solution
    commit = value_to_df_2dim(value.(COMMIT))

    # Calculate curtailment
    curtail = innerjoin(gen_var_cf, gen, on = [:r_id, :hour])
    curtail.curt = curtail.cf .* curtail.existing_cap_mw - curtail.gen

    # Return the solution and objective as named tuple
    return (
        gen,
        commit,
        curtail,
        cost = objective_value(UC),
        status = termination_status(UC)
    )

end
```

    unit_commitment_ramp (generic function with 1 method)

### 4. Solve a day's unit commitment

```julia
solution = unit_commitment_ramp(gen_df_sens, loads_multi, gen_variable_multi);

# Add in BTM solar and curtailment and plot results

sol_gen = innerjoin(solution.gen,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_gen = combine(groupby(sol_gen, [:resource, :hour]),
            :gen => sum)

# Rename generators (for plotting purposes)
sol_gen[sol_gen.resource .== "solar_photovoltaic", :resource] .= "_solar_photovoltaic"
sol_gen[sol_gen.resource .== "onshore_wind_turbine", :resource] .= "_onshore_wind_turbine"
sol_gen[sol_gen.resource .== "small_hydroelectric", :resource] .= "_small_hydroelectric"

# BTM solar
btm = DataFrame(resource = repeat(["_solar_photovoltaic_btm"]; outer=length(T_period)),
    hour = T_period,
    gen_sum = gen_variable_multi[gen_variable_multi.gen_full .== "wec_sdge_solar_photovoltaic_1.0",:cf] * 600)
append!(sol_gen, btm)

# Curtailment
curtail = combine(groupby(solution.curtail, [:hour]),
            :curt => sum)
curtail[!, :resource] .= "_curtailment"
rename!(curtail, :curt_sum => :gen_sum)
append!(sol_gen, curtail[:,[:resource, :hour, :gen_sum]])

# Rescale hours
sol_gen.hour = sol_gen.hour .- T_period[1]

sol_gen |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

In contrast to when we added ramp constraints to the Economic Dispatch problem, adding ramp constraints to our Unit Commitment formulation does not change the solution as much (at least in this case). Much of the inflexibility here arises from the unit commitment constraints, and once committed, there is ample ramping capability. We do however get relatively more combustion turbines running to meet the evening ramp, given their faster ramp rates.

```julia
sol_commit = innerjoin(solution.commit,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_commit = combine(groupby(sol_commit, [:resource, :hour]),
            :gen => sum)
sol_commit.hour = sol_commit.hour .- T_period[1]

sol_commit |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

A case involving more large coal or nuclear units with slower ramping limits than gas turbines or combined cycle plants might find ramp constraints more limiting. As in many cases, the impact of constraints is case dependent, and models must exercise some judicious application of domain knowledge (and experimentation with alternative formulations) to decide how complex your model should be to reflect important *binding* constraints that actually shape your outcomes of interest. Where constraints are non-binding or second order, it may be more practical to omit them entirely, reducing the computational intensity (solve time) of your model...

## Unit commitment with ramping and reserves

We now add spinning reserve requirements to the model. Spinning reserves refer to generation capacity that is online and able to generate (e.g., within 10-30 minutes) if needed by the system operator. (Note: in Europe, these are referred to as tertiary reserves.) The SO will establish reserve requirements to maintain sufficient capacity to respond to demand or supply forecast errors or in case of "contingencies," such as the unplanned and sudden loss of a generating station or a transmission line. The SO then typically operates a reserve market to competitively procure this available capacity. Most SO's actually define several classes of reserve products defined by their response time and period over which they may be activated.

Here we will focus on spinning reserve requirements and establish a simple set of reserve requirements for reserves up (ability to quickly increase output) and reserves down (ability to quickly reduce output):

$$
\begin{align}
 & ResReqUp_t = 300 MW + 5\% \times Demand_t  &\forall \quad t \in T \\
 & ResReqDn_t = 5\% \times Demand_t & \forall \quad t \in T
\end{align}
$$

Here, $300 MW$ is our contigency reserves, meant to ensure we have sufficient upwards ramping capability online and available to cover the unexpected loss of 300 MW worth of generation.

The 5% of demand term in the constraints above is meant to provide sufficient reserves in either upwards or downwards direction to cover errors in the demand forecast. For simplicity, we ignore solar PV and wind production in our reserve calculation, though in practice, we would want to consider solar and wind forecast errors in calculating up reserve requirements as well.

In our simple system, only thermal generators provide reserves. (This can be easily adjusted by changing the sets and defining the potential reserve contributions for different sets of resources differently, such as storage or hydro resources). The contribution of each generators to meeting reserves and the overall reserve constraint are thus given by:

$$
\begin{align}
 & RESUP_{g,t} \leq Pmax_{g,t}COMMIT_{g,t} - GEN_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & RESDN_{g,t} \leq GEN_{g,t} - Pmin_{g,t}COMMIT_{g,t} & \forall \quad g \in G_{thermal} , t \in T \\
 & RESUP_{g,t} \leq RampUp_{g} & \forall \quad g \in G_{thermal}, t \in T\\
 & RESDN_{g,t} \leq RampDn_{g} & \forall \quad g \in G_{thermal}, t \in T \\
 & \sum_{g \in G_{thermal}} RESUP_{g,t} \geq ResReqUp_t & \forall \quad t \in T \\
 & \sum_{g \in G_{thermal}} RESDN_{g,t} \geq ResReqDn_t & \forall \quad t \in T
\end{align}
$$

We have added two new **decision variables**:

- $RESUP_{g,t}$, up-reserve capacity (in MW) of generator $g$ at time $t$
- $RESDN_{g,t}$, down-reserve capacity (in MW) of generator $g$ at time $t$

Note that in this case, we constrain the reserve contribution for each thermal generator to be the same as their hourly ramp rates, $RampUp_{g}, RampDown_{g}$. In practice, reserve products typically require a faster response time, on the order of 10-30 minutes for "tertiary" reserves (aka spinning reserves or contingency reserves), and 5-15 minutes for "secondary" reserves (regulation reserves). Thus, depending on the reserve requirements being modeled, one might specify a distinct maximum reserve contribution for each unit that reflects their ramping capabilities over shorter time periods.

### 3. Create solver function

(We reuse steps 1 and 2 above to load packages and data.)

```julia
#=
Function to solve full unit commitment problem (commitment, ramp, reserve equations)
Inputs:
    gen_df -- dataframe with generator info
    loads  -- load by time
    gen_variable -- capacity factors of variable generators (in "long" format)
=#
function unit_commitment_full(gen_df, loads, gen_variable)
    UC = Model(GLPK.Optimizer)
    # To keep solve times to a few minutes, we increase the MIP gap tolerance
    # in this more complex case to 2%. You can play with this setting to see
    # how the MIP gap affects solve times on your computer.
    set_optimizer_attribute(UC, "mip_gap", 0.02)

    # Define sets based on data
    # Note the creation of several different sets of generators for use in
    # different equations.
    G_thermal = gen_df[gen_df[!,:up_time] .> 0,:r_id]
    G_nonthermal = gen_df[gen_df[!,:up_time] .== 0,:r_id]
    G_var = gen_df[gen_df[!,:is_variable] .== 1,:r_id]
    G_nonvar = gen_df[gen_df[!,:is_variable] .== 0,:r_id]
    G_nt_nonvar = intersect(G_nonvar, G_nonthermal)
    G = gen_df.r_id
    T = loads.hour
    T_red = loads.hour[1:end-1]  # reduced time periods without last one

    # Generator capacity factor time series for variable generators
    gen_var_cf = innerjoin(gen_variable,
                    gen_df[gen_df.is_variable .== 1 ,
                        [:r_id, :gen_full, :existing_cap_mw, :resource]],
                    on = :gen_full)
    gen_var_cf.max_gen = gen_var_cf.cf .* gen_var_cf.existing_cap_mw

    # Compute reserve requirements
    ResReqUp = Dict(T[i] => 300
              + 0.05 * loads[loads.hour .== T[i],:demand][1]
                    for i in 1:length(T))
    ResReqDn = Dict(T[i] => 0
              + 0.05 * loads[loads.hour .== T[i],:demand][1]
                    for i in 1:length(T))

    # Decision variables
    @variables(UC, begin
        GEN[G, T]  >= 0     # generation
        GENAUX[G_thermal, T] >= 0 # auxiliary generation variable
        COMMIT[G_thermal, T], Bin # commitment status (Bin=binary)
        START[G_thermal, T], Bin  # startup decision
        SHUT[G_thermal, T], Bin   # shutdown decision
        RESUP[G_thermal, T]       # up reserve capacity
        RESDN[G_thermal, T]       # down reserve capacity
    end)

    # Objective function
    @objective(UC, Min,
        sum( (gen_df[gen_df.r_id .== i,:heat_rate_mmbtu_per_mwh][1] * gen_df[gen_df.r_id .== i,:fuel_cost][1] +
            gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1]) * GEN[i,t]
                        for i in G_nonvar for t in T) +
        sum(gen_df[gen_df.r_id .== i,:var_om_cost_per_mwh][1] * GEN[i,t]
                        for i in G_var for t in T) +
        sum(gen_df[gen_df.r_id .== i,:start_cost_per_mw][1] *
            gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
            START[i,t]
                        for i in G_thermal for t in T)
    )

    # Demand constraint
    @constraint(UC, cDemand[t in T],
        sum(GEN[i,t] for i in G) == loads[loads.hour .== t,:demand][1])

    # Capacity constraints (thermal generators requiring commitment)
    @constraint(UC, Cap_thermal_min[i in G_thermal, t in T],
        GEN[i,t] >= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])
    @constraint(UC, Cap_thermal_max[i in G_thermal, t in T],
        GEN[i,t] <= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

    # Capacity constraints (non-variable generation not requiring commitment)
    @constraint(UC, Cap_nt_nonvar[i in G_nt_nonvar, t in T],
        GEN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1])

    # Capacity constraints (variable generation)
    @constraint(UC, Cap_var[i in 1:nrow(gen_var_cf)],
            GEN[gen_var_cf[i,:r_id], gen_var_cf[i,:hour] ] <=
                        gen_var_cf[i,:max_gen])

    # Unit commitment constraints
    @constraint(UC, Startup[i in G_thermal, t in T],
        COMMIT[i, t] >= sum(START[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:up_time][1]):t)))

    @constraint(UC, Shutdown[i in G_thermal, t in T],
        1-COMMIT[i, t] >= sum(SHUT[i, tt]
                        for tt in intersect(T,
                            (t-gen_df[gen_df.r_id .== i,:down_time][1]):t)))

    @constraint(UC, CommitmentStatus[i in G_thermal, t in T_red],
        COMMIT[i,t+1] - COMMIT[i,t] == START[i,t+1] - SHUT[i,t+1])

    # Auxiliary variable
    @constraint(UC, AuxGen[i in G_thermal, t in T],
        GENAUX[i,t] == GEN[i,t] - COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])

    # Ramp equations (thermal generators)
    @constraint(UC, RampUp_thermal[i in G_thermal, t in T_red],
        GENAUX[i,t+1] - GENAUX[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_up_percentage][1] )

    @constraint(UC, RampDn_thermal[i in G_thermal, t in T_red],
        GENAUX[i,t] - GENAUX[i,t+1] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_dn_percentage][1] )

    # Ramp equations (non-thermal generators)
    @constraint(UC, RampUp_nonthermal[i in G_nonthermal, t in T_red],
        GEN[i,t+1] - GEN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_up_percentage][1] )

    @constraint(UC, RampDn[i in G, t in T_red],
        GEN[i,t] - GEN[i,t+1] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                                 gen_df[gen_df.r_id .== i,:ramp_dn_percentage][1] )

    # Reserve equations
    # (1) Reserves limited by committed capacity of generator
    @constraint(UC, ResUpCap[i in G_thermal, t in T],
        RESUP[i,t] <= COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1]
                        - GEN[i,t])

    @constraint(UC, ResDnCap[i in G_thermal, t in T],
        RESDN[i,t] <= GEN[i,t] -
                        COMMIT[i, t] * gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                        gen_df[gen_df.r_id .== i,:min_power][1])

    # (2) Reserves limited by ramp rates
    @constraint(UC, ResUpRamp[i in G_thermal, t in T],
        RESUP[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                          gen_df[gen_df.r_id .== i,:ramp_up_percentage][1])

    @constraint(UC, ResDnRamp[i in G_thermal, t in T],
        RESDN[i,t] <= gen_df[gen_df.r_id .== i,:existing_cap_mw][1] *
                          gen_df[gen_df.r_id .== i,:ramp_dn_percentage][1])

    # (3) Overall reserve requirements
    @constraint(UC, ResUpRequirement[t in T],
        sum(RESUP[i,t] for i in G_thermal) >=
                ResReqUp[t])

    @constraint(UC, ResDnRequirement[t in T],
        sum(RESDN[i,t] for i in G_thermal) >=
                ResReqDn[t])

    # Solve statement (! indicates runs in place)
    optimize!(UC)

    # Generation solution
    gen = value_to_df_2dim(value.(GEN))

    # Commitment status solution
    commit = value_to_df_2dim(value.(COMMIT))

    # Calculate curtailment
    curtail = innerjoin(gen_var_cf, gen, on = [:r_id, :hour])
    curtail.curt = curtail.cf .* curtail.existing_cap_mw - curtail.gen

    # Return the solution and objective as named tuple
    return (
        gen,
        commit,
        curtail,
        cost = objective_value(UC),
        status = termination_status(UC)
    )

end
```

    unit_commitment_full (generic function with 1 method)

### 4. Solve the model and plot results

**Note: this might take a little while depending on your machine.**

```julia
solution = unit_commitment_full(gen_df_sens, loads_multi, gen_variable_multi);

# Add in BTM solar and curtailment and plot results

sol_gen = innerjoin(solution.gen,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_gen = combine(groupby(sol_gen, [:resource, :hour]),
            :gen => sum)

# Rename generators (for plotting purposes)
sol_gen[sol_gen.resource .== "solar_photovoltaic", :resource] .= "_solar_photovoltaic"
sol_gen[sol_gen.resource .== "onshore_wind_turbine", :resource] .= "_onshore_wind_turbine"
sol_gen[sol_gen.resource .== "small_hydroelectric", :resource] .= "_small_hydroelectric"

# BTM solar
btm = DataFrame(resource = repeat(["_solar_photovoltaic_btm"]; outer=length(T_period)),
    hour = T_period,
    gen_sum = gen_variable_multi[gen_variable_multi.gen_full .== "wec_sdge_solar_photovoltaic_1.0",:cf] * 600)
append!(sol_gen, btm)

# Curtailment
curtail = combine(groupby(solution.curtail, [:hour]),
            :curt => sum)
curtail[!, :resource] .= "_curtailment"
rename!(curtail, :curt_sum => :gen_sum)
append!(sol_gen, curtail[:,[:resource, :hour, :gen_sum]])

# Rescale hours
sol_gen.hour = sol_gen.hour .- T_period[1]

sol_gen |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

We can now see that the need to maintain reserve requirements leads to greater commitment of natural gas units throughout the day. The addition of CCGTs and GTs to meet reserve requirements leads to greater curtailment of wind and solar in the afternoon hours. We also see more CT units committed during the morning ramp as well as the afternoon hours to help provide reserves.

```julia
sol_commit = innerjoin(solution.commit,
                    gen_df[!, [:r_id, :resource]],
                    on = :r_id)
sol_commit = combine(groupby(sol_commit, [:resource, :hour]),
            :gen => sum)
sol_commit.hour = sol_commit.hour .- T_period[1]

sol_commit |>
@vlplot(:area,
    x=:hour, y={:gen_sum, stack=:zero},
    color={"resource:n", scale={scheme="category10"}})
```

### Further resources

Knueven, B., Ostrowski, J., & Watson, J.-P. (2019). On Mixed Integer Programming Formulations for the Unit Commitment Problem. Optimization Online, 91. http://www.optimization-online.org/DB_FILE/2018/11/6930.pdf

Morales-Espana, G., Latorre, J. M., & Ramos, A. (2013). Tight and Compact MILP Formulation of Start-Up and Shut-Down Ramping in Unit Commitment. IEEE Transactions on Power Systems, 28(2), 1288–1296. https://doi.org/10.1109/TPWRS.2012.2222938

Morales-España, G., Ramírez-Elizondo, L., & Hobbs, B. F. (2017). Hidden power system inflexibilities imposed by traditional unit commitment formulations. Applied Energy, 191, 223–238. https://doi.org/10.1016/j.apenergy.2017.01.089

Ostrowski, J., Anjos, M. F., & Vannelli, A. (2012). Tight Mixed Integer Linear Programming Formulations for the Unit Commitment Problem. IEEE Transactions on Power Systems, 27(1), 39–46. https://doi.org/10.1109/TPWRS.2011.2162008


### Setting up the environment

**Create**:

environment.yml
```
name: grid-vs-random-search
channels:
  - conda-forge
dependencies:
  - python=3.6.9
  - numpy=1.16.5
  - matplotlib=3.1.1
  - jupyter=1.0.0
  - ipython=7.8.0
  - hyperopt=0.1.2
```

**Execute**:

```$ conda env create -f environment.yml```

```$ conda activate grid-vs-random-search```

### Configuring inline plotting

```python
import matplotlib.pyplot as plt

%matplotlib inline
```

### Defining the cost function
$
\begin{align}
f(x) & = 675 + (x-15)^2 + x^2 cos(x\pi)
\end{align}
$

```python
import numpy as np

non_convex_function = lambda x: 675 + (x-15)**2 + x**2 * np.cos(x*np.pi)
```

### Plotting the cost function

```python
def setup_ax():
    """
    Set up the plot axis to look prettier.
    """
    ax = plt.gca()
    ax.set_axisbelow(True)
    ax.grid(True, color='lightgrey', linestyle='-', alpha=0.4)
    ax.tick_params(axis='both', which='both', length=0, labelcolor='0.5')
    ax.spines['right'].set_visible(False)
    ax.spines['top'].set_visible(False)
    ax.spines['left'].set_visible(False)
    ax.spines['bottom'].set_visible(False)

def plot_function(f, x_min, x_max):
    """
    Plot the given function in the interval [x_min, x_max].
    :param f: function to plot
    :param x_min: left side of the interval
    :param x_max: right side of the interval
    """
    plt.figure(facecolor="white", figsize=(16, 6))

    setup_ax()

    x = np.arange(x_min, x_max, 0.01)

    plt.plot(x,
             f(x),
             c='r',
             zorder=0,
             label='function')

    plt.legend(loc='upper left')

# Intervals for the experiments [X_MIN, X_MAX]
X_MIN = -10
X_MAX = 30

plot_function(non_convex_function, X_MIN, X_MAX)
```

### Defining some helper code

```python
from hyperopt import fmin, rand, hp, Trials

# Random states to make the experiments reproducible
RANDOM_STATES = [1, 2, 3, 4]

def get_best_value(f, candidates):
    """
    Return the candidate that yielded the lowest cost in the function f.
    :param f: cost function
    :param candidates: x candidates
    :return: the best candidate
    """
    idx_min = np.argmin(f(candidates))
    return candidates[idx_min]

def plot_search(search_type, f, candidates, x_min, x_max, selected_value):
    """
    Plot the given function in the interval [x_min, x_max],
    search candidates and selected value.
    :param search_type: the type of the search to be set as title
    :param f: cost function
    :param candidates: search candidates
    :param x_min: left side of the interval
    :param x_max: right side of the interval
    :param selected_value: selected value among the candidates
    """
    # Cost function
    plot_function(f, x_min, x_max)

    # Candidates
    plt.scatter(candidates,
                f(candidates),
                color='firebrick',
                zorder=1,
                label='candidate')

    # Best value
    plt.scatter(selected_value,
                f(selected_value),
                color='steelblue',
                marker='*',
                s=200,
                zorder=2,
                label='best value: {0:.2f}'.format(selected_value))

    plt.title('[{0}] Cost for best value: {1:.2f}' \
        .format(search_type, f(selected_value)))
    plt.legend(loc='upper left')

def run_grid_search_experiment(f, n_trials, show_plot):
    """
    Run the experiment for grid search.
    :param f: cost function
    :param n_trials: number of trials
    :param show_plot: show plot if true
    :return: the best candidate
    """
    gs_candidates = np.linspace(X_MIN, X_MAX, n_trials)
    selected_value = get_best_value(f, gs_candidates)

    if show_plot:
        plot_search('Grid Search',
                    f,
                    gs_candidates,
                    X_MIN,
                    X_MAX,
                    selected_value)

    return selected_value

def run_random_search_experiment(f, n_trials, random_state, show_plot):
    """
    Run the experiment for random search.
    :param f: cost function
    :param n_trials: number of trials
    :param random_state: value to make the experiment reproducible
    :param show_plot: show plot if true
    :return: the best candidate
    """
    trials = Trials()

    fmin(fn=f,
         space=hp.uniform('x', X_MIN, X_MAX),
         algo=rand.suggest,
         max_evals=n_trials,
         trials=trials,
         rstate=np.random.RandomState(random_state),
         show_progressbar=False)

    rs_candidates = \
        np.array([t['misc']['vals']['x'] for t in trials.trials]).flatten()

    # note: fmin already returns the best value, but let's use our function :)
    selected_value = get_best_value(f, rs_candidates)

    if show_plot:
        plot_search('Random Search',
                    f,
                    rs_candidates,
                    X_MIN,
                    X_MAX,
                    selected_value)

    return selected_value

def run_experiments(f, n_trials, random_state, show=False):
    """
    Run the experiments for grid search and random search.
    :param f: cost function
    :param n_trials: number of trials
    :param random_state: value to make the experiment reproducible
    :param show: show plot and winner if true
    :return: the best candidates found for grid search and random search
    """
    gs_selected_value = run_grid_search_experiment(f,
                                                   n_trials,
                                                   show)

    rs_selected_value = run_random_search_experiment(f,
                                                     n_trials,
                                                     random_state,
                                                     show)

    if show:
        print_winner(f,
                     gs_selected_value,
                     rs_selected_value)

    return gs_selected_value, rs_selected_value

def has_gs_won(f, gs, rs):
    """
    Evaluate the best candidates in the function f
    and return whether grid search has won or not.
    The lower cost the better.
    For simplicity, a tie gives the victory to grid search.
    :param f: cost function
    :param gs: best value found with grid search
    :param rs: best value found with random search
    """
    cost_gs = f(gs)
    cost_rs = f(rs)

    return cost_gs <= cost_rs

def print_winner(f, gs, rs):
    """
    Evaluate the best candidates in the function f and print the winner.
    :param f: cost function
    :param gs: best value found with grid search
    :param rs: best value found with random search
    """

    if has_gs_won(f, gs, rs):
        print('Grid Search is the winner!')
    else:
        print('Random Search is the winner!')
```

### Experiment - 10 trials

```python
gs, rs = run_experiments(non_convex_function,
                         10,
                         RANDOM_STATES[0],
                         show=True)
```

### Experiment - 50 trials

```python
gs, rs = run_experiments(non_convex_function,
                         50,
                         RANDOM_STATES[1],
                         show=True)
```

### Experiment - 100 trials

```python
gs, rs = run_experiments(non_convex_function,
                         100,
                         RANDOM_STATES[2],
                         show=True)
```

### Experiment - 500 trials

```python
gs, rs = run_experiments(non_convex_function,
                         500,
                         RANDOM_STATES[3],
                         show=True)
```

### Running batch experiments

```python
def run_batch_experiments(f, n_trials):
    """
    Run a batch of experiments and print the results.
    :param f: cost function
    :param n_trials: the number of trials which is also the number of experiments
    """
    # Random states to make the experiments reproducible
    rstates = np.arange(1, n_trials+1)

    # Count how many times each search strategy has won
    gs_count = 0
    rs_count = 0

    gs_cost_sum = 0
    rs_cost_sum = 0

    for rstate in rstates:
        gs, rs = run_experiments(f,
                                 n_trials,
                                 rstate,
                                 show=False)
        gs_won = has_gs_won(f, gs, rs)
        gs_count += int(gs_won == True)
        rs_count += int(gs_won == False)

        gs_cost_sum += f(gs)
        rs_cost_sum += f(rs)

    print('Grid Search:\t{0}\t({1:.2f})'.format(gs_count, gs_cost_sum))
    print('Random Search:\t{0}\t({1:.2f})'.format(rs_count, rs_cost_sum))
```

```python
run_batch_experiments(non_convex_function,
                      10)
```

    Grid Search:	5	(2935.44)
    Random Search:	5	(3692.77)

```python
run_batch_experiments(non_convex_function,
                      50)
```

    Grid Search:	16	(8119.40)
    Random Search:	34	(6885.77)

```python
run_batch_experiments(non_convex_function,
                      100)
```

    Grid Search:	8	(17449.96)
    Random Search:	92	(9118.30)

```python
run_batch_experiments(non_convex_function,
                      500)
```

    Grid Search:	221	(17441.61)
    Random Search:	279	(20750.24)