Note
This is example is available as a Jupyter notebook. Download it and all
necessary data files here
.
Unit Commitment¶
This examples covers Unit Commitment, a classical operations research problem that arises in the operation of electrical networks. In this problem, multiple power generation units with different characteristics are dispatched to meet expected electricity demand. A unit can be on or off, with a startup cost associated with transitioning from off to on, and power output that must lie in a specified range while the unit is on. The model is specified over discrete time periods, and decides which units to turn on, and when, in order to satisfy demand for each time period. The model also captures a reserve requirement, where the selected power plants must be capable of increasing their output, while still respecting their maximum output, in order to cope with the situation where actual demand exceeds predicted demand.
This model is based on example 15 from the fifth edition of Model Building in Mathematical Programming, by H. Paul Williams on pages 270-271 and 325-326, and is adapted from an existing Gurobi notebook here which uses Python data structures to build the model.
[1]:
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
import gurobipy_pandas as gppd
gppd.set_interactive()
Data Schema¶
Each generator has properties which remain fixed over all time periods:
num_available
: number of available generating unitsmin_output
: minimum generation in MWh for each active generatormax_output
: maximum generation in MWh for each active generatorcost_per_hour
: cost per hour per active generatormarginal cost
: cost per MWh for generation above min_outputstartup_cost
: fixed cost incurred for starting a generator in an intervalstate0
: number of generators active before the first period
Input data for the generators is stored in a DataFrame with the generator class name as the index.
[3]:
# Load and check the generator data
generator_data = pd.read_csv(
"data/generators.csv",
index_col="generator_class",
)
generator_data
[3]:
num_available | min_output | max_output | cost_per_hour | marginal_cost | startup_cost | state0 | |
---|---|---|---|---|---|---|---|
generator_class | |||||||
thermal1 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 |
thermal2 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 |
thermal3 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 |
Each time period has the following data:
expected_demand
: predicted MWh demand, which the solution will meet exactlyminimum_capacity
: value in MWh above the predicted demand; the total online generation capacity must exceed this value
Input data for the time periods is stored in a DataFrame with the time periods as the index.
[4]:
# Load and check the time period data
time_period_data = pd.read_csv(
"data/time_periods.csv",
parse_dates=["time_period"],
index_col="time_period",
)
time_period_data
[4]:
expected_demand | minimum_active_capacity | |
---|---|---|
time_period | ||
2024-07-19 06:00:00 | 15000.0 | 17250.0 |
2024-07-19 07:00:00 | 30000.0 | 34500.0 |
2024-07-19 08:00:00 | 25000.0 | 28750.0 |
2024-07-19 09:00:00 | 40000.0 | 46000.0 |
2024-07-19 10:00:00 | 27000.0 | 31050.0 |
Create the model¶
[5]:
model = gp.Model()
Add Time-Expanded Variables¶
The model has three variable types capturing the state of each generator class:
output
: The total output of all generators in the class in the given time period (continuous)num_active
: The number of active generators of the class in the given time period (integer, upper bounded by number of available generators)num_startup
: The number of active generators of the class which start up in the given time period (integer)
One variable of each type is needed for every generator class and time period. To create this ‘time-expanded’ formulation we need to take the product of the two indexes in our input data. This is done using pandas’ MultiIndex.from_product method.
Using this time-expanded index, we’ll then use the DataFrame accessors from gurobipy-pandas to create our variables.
[6]:
# Simplifies variable names
short_time = {"time_period": lambda index: index.strftime("%H%M")}
# Construct time-expanded index and add variables
generators = (
# Create a new dataframe for the time-expanded index
pd.DataFrame(
index=pd.MultiIndex.from_product([generator_data.index, time_period_data.index])
)
.join(generator_data)
# Create continuous variables (one per row) for generator output
.gppd.add_vars(model, name="output", index_formatter=short_time)
# Create integer variables for the number of active generators
.gppd.add_vars(
model,
vtype=GRB.INTEGER,
ub="num_available", # Use num_available from the input data as a bound
name="num_active",
index_formatter=short_time,
)
# Create non-negative integer variables capturing generator startups
.gppd.add_vars(
model, vtype=GRB.INTEGER, name="num_startup", index_formatter=short_time
)
)
The resulting generators
DataFrame will be used to create constraints. Note that it contains both data columns and Gurobi variables as columns. This allows us to use standard pandas operations to build constraint expressions.
[7]:
generators
[7]:
num_available | min_output | max_output | cost_per_hour | marginal_cost | startup_cost | state0 | output | num_active | num_startup | ||
---|---|---|---|---|---|---|---|---|---|---|---|
generator_class | time_period | ||||||||||
thermal1 | 2024-07-19 06:00:00 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 | <gurobi.Var output[thermal1,0600]> | <gurobi.Var num_active[thermal1,0600]> | <gurobi.Var num_startup[thermal1,0600]> |
2024-07-19 07:00:00 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 | <gurobi.Var output[thermal1,0700]> | <gurobi.Var num_active[thermal1,0700]> | <gurobi.Var num_startup[thermal1,0700]> | |
2024-07-19 08:00:00 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 | <gurobi.Var output[thermal1,0800]> | <gurobi.Var num_active[thermal1,0800]> | <gurobi.Var num_startup[thermal1,0800]> | |
2024-07-19 09:00:00 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 | <gurobi.Var output[thermal1,0900]> | <gurobi.Var num_active[thermal1,0900]> | <gurobi.Var num_startup[thermal1,0900]> | |
2024-07-19 10:00:00 | 12 | 850.0 | 2000.0 | 1000.0 | 2.0 | 2000.0 | 0 | <gurobi.Var output[thermal1,1000]> | <gurobi.Var num_active[thermal1,1000]> | <gurobi.Var num_startup[thermal1,1000]> | |
thermal2 | 2024-07-19 06:00:00 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 | <gurobi.Var output[thermal2,0600]> | <gurobi.Var num_active[thermal2,0600]> | <gurobi.Var num_startup[thermal2,0600]> |
2024-07-19 07:00:00 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 | <gurobi.Var output[thermal2,0700]> | <gurobi.Var num_active[thermal2,0700]> | <gurobi.Var num_startup[thermal2,0700]> | |
2024-07-19 08:00:00 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 | <gurobi.Var output[thermal2,0800]> | <gurobi.Var num_active[thermal2,0800]> | <gurobi.Var num_startup[thermal2,0800]> | |
2024-07-19 09:00:00 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 | <gurobi.Var output[thermal2,0900]> | <gurobi.Var num_active[thermal2,0900]> | <gurobi.Var num_startup[thermal2,0900]> | |
2024-07-19 10:00:00 | 10 | 1250.0 | 1750.0 | 2600.0 | 1.3 | 1000.0 | 0 | <gurobi.Var output[thermal2,1000]> | <gurobi.Var num_active[thermal2,1000]> | <gurobi.Var num_startup[thermal2,1000]> | |
thermal3 | 2024-07-19 06:00:00 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 | <gurobi.Var output[thermal3,0600]> | <gurobi.Var num_active[thermal3,0600]> | <gurobi.Var num_startup[thermal3,0600]> |
2024-07-19 07:00:00 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 | <gurobi.Var output[thermal3,0700]> | <gurobi.Var num_active[thermal3,0700]> | <gurobi.Var num_startup[thermal3,0700]> | |
2024-07-19 08:00:00 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 | <gurobi.Var output[thermal3,0800]> | <gurobi.Var num_active[thermal3,0800]> | <gurobi.Var num_startup[thermal3,0800]> | |
2024-07-19 09:00:00 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 | <gurobi.Var output[thermal3,0900]> | <gurobi.Var num_active[thermal3,0900]> | <gurobi.Var num_startup[thermal3,0900]> | |
2024-07-19 10:00:00 | 5 | 1500.0 | 4000.0 | 3000.0 | 3.0 | 500.0 | 0 | <gurobi.Var output[thermal3,1000]> | <gurobi.Var num_active[thermal3,1000]> | <gurobi.Var num_startup[thermal3,1000]> |
Demand Constraints¶
There are two types of demand constraints:
The total output of all generators in each time period must match the expected demand
The active generators in each time period must be able to meet the reserve demand
[8]:
# Constrain that predicted demand is exactly satisfied
demand_constraint = gppd.add_constrs(
model,
generators.groupby("time_period")["output"].sum(),
GRB.EQUAL,
time_period_data["expected_demand"],
index_formatter=short_time,
)
[9]:
# Constrain that the active generators during each time
# period are capable of meeting the reserve demand.
active_capacity = (
(generators["max_output"] * generators["num_active"])
.groupby("time_period").sum()
).rename("active_capacity")
active_capacity_constraint = gppd.add_constrs(
model,
active_capacity,
GRB.GREATER_EQUAL,
time_period_data["minimum_active_capacity"],
index_formatter=short_time,
)
Note that we keep total online capacity as a series of expressions. This way we can directly use it in analysis of the results.
[10]:
active_capacity.to_frame()
[10]:
active_capacity | |
---|---|
time_period | |
2024-07-19 06:00:00 | 2000.0 num_active[thermal1,0600] + 1750.0 num_... |
2024-07-19 07:00:00 | 2000.0 num_active[thermal1,0700] + 1750.0 num_... |
2024-07-19 08:00:00 | 2000.0 num_active[thermal1,0800] + 1750.0 num_... |
2024-07-19 09:00:00 | 2000.0 num_active[thermal1,0900] + 1750.0 num_... |
2024-07-19 10:00:00 | 2000.0 num_active[thermal1,1000] + 1750.0 num_... |
Output Constraints¶
Each generator class is constrained within it’s operating limits.
[11]:
df = (
generators
.gppd.add_constrs(
model,
"output >= min_output * num_active",
name="lower_limit",
index_formatter=short_time,
)
.gppd.add_constrs(
model,
"output <= max_output * num_active",
name="constr_max_output",
index_formatter=short_time,
)
)
Startup Constraints¶
The startup variables will be used to capture the cost associated with starting a generator during a time period. For this we need a rolling-window constraint such that startups capture the difference between the number of generators online in adjacent time periods.
[12]:
# Constrain the relationship between active generators and startups.
def startup_constraints(group):
group = group.sort_index()
return gppd.add_constrs(
model,
group["num_startup"].iloc[1:],
GRB.GREATER_EQUAL,
group["num_active"].diff().dropna(),
name="startup",
index_formatter=short_time,
)
startup = generators.groupby("generator_class").apply(startup_constraints).droplevel(0)
This groupby + diff operation creates constraints of this form (these can be inspected by writing the model to an LP file using model.write("unit-commitment.lp")
):
startup[thermal1,0900]: num_active[thermal1,0800]
- num_active[thermal1,0900] + num_startup[thermal1,0900] >= 0
i.e. the number of generators started at 9am, is at least as large as difference between the number of active generators at 9am and the number of active generators at 8am. num_startup
is a non-negative integer, and it has a cost penalty associated with it in the objective function, so we can be sure it will capture the number of startups correctly.
[13]:
# Separately capture the startups at time period 0.
time_period_1 = generators.sort_index().groupby("generator_class").first()
initial_startup = gppd.add_constrs(
model,
time_period_1["num_startup"],
GRB.GREATER_EQUAL,
time_period_1["num_active"] - generator_data["state0"],
name="initial_startup",
index_formatter=short_time,
)
Objective Function¶
The total cost objective is now easy to compute:
[14]:
# Minimize total cost objective
model.setObjective(
(
# Fixed hourly costs for started generators
generators["cost_per_hour"] * generators["num_active"]
# Marginal hourly cost of additional generation above the minimum
+ generators["marginal_cost"]
* (generators["output"] - generators["num_active"] * generators["min_output"])
# Startup costs for newly active generators in each time period
+ generators["startup_cost"] * generators["num_startup"]
).sum(),
sense=GRB.MINIMIZE
)
Solve the model¶
[15]:
model.optimize()
Gurobi Optimizer version 12.0.0 build v12.0.0rc1 (linux64 - "Ubuntu 22.04.3 LTS")
CPU model: Intel(R) Xeon(R) Platinum 8175M CPU @ 2.50GHz, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 1 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 55 rows, 45 columns and 132 nonzeros
Model fingerprint: 0xf6611d63
Variable types: 15 continuous, 30 integer (0 binary)
Coefficient statistics:
Matrix range [1e+00, 4e+03]
Objective range [1e+00, 2e+03]
Bounds range [5e+00, 1e+01]
RHS range [2e+04, 5e+04]
Found heuristic solution: objective 327950.00000
Presolve removed 12 rows and 9 columns
Presolve time: 0.00s
Presolved: 43 rows, 36 columns, 113 nonzeros
Found heuristic solution: objective 319750.00000
Variable types: 8 continuous, 28 integer (0 binary)
Root relaxation: objective 2.563143e+05, 26 iterations, 0.00 seconds (0.00 work units)
Nodes | Current Node | Objective Bounds | Work
Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
0 0 256314.286 0 5 319750.000 256314.286 19.8% - 0s
H 0 0 257055.00000 256314.286 0.29% - 0s
H 0 0 256565.00000 256314.286 0.10% - 0s
H 0 0 256500.00000 256314.286 0.07% - 0s
Cutting planes:
Gomory: 1
MIR: 2
Explored 1 nodes (26 simplex iterations) in 0.02 seconds (0.00 work units)
Thread count was 2 (of 2 available processors)
Solution count 5: 256500 256565 257055 ... 327950
Optimal solution found (tolerance 1.00e-04)
Best objective 2.565000000000e+05, best bound 2.565000000000e+05, gap 0.0000%
Extract results¶
Results are extracted using Series accessors. Note that after extracting the results, we can close()
the model and proceed to analyse results using only pandas operations.
[16]:
# Extract all variable values
solution = pd.DataFrame(
dict(
output=generators["output"].gppd.X,
num_active=generators["num_active"].gppd.X,
num_startup=generators["num_startup"].gppd.X,
)
)
# Extract some additional results for comparison
results = pd.DataFrame(
{
"Demand": time_period_data["expected_demand"],
"Min. Active Capacity": time_period_data["minimum_active_capacity"],
"Active Capacity": active_capacity.gppd.get_value(),
"Excess": (-active_capacity_constraint.gppd.Slack),
}
)
# After extracting all results, close the model.
model.close()
Analysis¶
Briefly; show the solution meeting the reserve demand and the excess capacity online.
[17]:
%matplotlib inline
%config InlineBackend.figure_formats = ['svg']
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_palette(sns.color_palette("deep"))
plt.figure(figsize=(8, 4))
results.plot.line(ax=plt.gca())
plt.xlabel("Time")
plt.ylabel("MWh")
plt.ylim([0, 50000])
plt.legend(loc=2);