Skip to content

Commit 21e831f

Browse files
authored
Add unit commitment example (#87)
1 parent 6c67c61 commit 21e831f

6 files changed

+320
-0
lines changed

docs/requirements-examples.txt

+1
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ jupytext>=1.14
44
matplotlib>=3.5
55
nbconvert>=6.5
66
scikit-learn>=1.0
7+
seaborn==0.13.2
78
gurobipy>=10.0

docs/requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ nbconvert==7.2.5
1111
nbsphinx==0.8.9
1212
numpydoc==1.6.0
1313
scikit-learn==1.5.0
14+
seaborn==0.13.2
1415
sphinx-copybutton==0.5.2
1516
sphinx-tabs==3.4.5
1617
sphinx-toolbox==3.5.0

docs/source/examples.rst

+1
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ the notebooks.
1616

1717
examples/projects
1818
examples/regression
19+
examples/unit-commitment
1920
examples/workforce
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
generator_class,num_available,min_output,max_output,cost_per_hour,marginal_cost,startup_cost,state0
2+
thermal1,12,850.0,2000.0,1000.0,2.0,2000.0,0
3+
thermal2,10,1250.0,1750.0,2600.0,1.30,1000.0,0
4+
thermal3,5,1500.0,4000.0,3000.0,3.0,500.0,0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
time_period,expected_demand,minimum_active_capacity
2+
2024-07-19 06:00:00,15000.0,17250.0
3+
2024-07-19 07:00:00,30000.0,34500.0
4+
2024-07-19 08:00:00,25000.0,28750.0
5+
2024-07-19 09:00:00,40000.0,46000.0
6+
2024-07-19 10:00:00,27000.0,31050.0
+307
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
---
2+
jupytext:
3+
formats: ipynb,md:myst
4+
text_representation:
5+
extension: .md
6+
format_name: myst
7+
format_version: 0.13
8+
jupytext_version: 1.14.1
9+
---
10+
11+
# Unit Commitment
12+
13+
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.
14+
15+
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](https://github.com/Gurobi/modeling-examples/tree/master/electrical_power_generation) which uses Python data structures to build the model.
16+
17+
```{code-cell}
18+
import pandas as pd
19+
import gurobipy as gp
20+
from gurobipy import GRB
21+
import gurobipy_pandas as gppd
22+
23+
gppd.set_interactive()
24+
```
25+
26+
```{code-cell}
27+
:nbsphinx: hidden
28+
29+
# Hidden cell to avoid licensing messages
30+
# when docs are generated.
31+
with gp.Model():
32+
pass
33+
```
34+
35+
## Data Schema
36+
37+
Each generator has properties which remain fixed over all time periods:
38+
39+
- `num_available`: number of available generating units
40+
- `min_output`: minimum generation in MWh for each active generator
41+
- `max_output`: maximum generation in MWh for each active generator
42+
- `cost_per_hour`: cost per hour per active generator
43+
- `marginal cost`: cost per MWh for generation above min_output
44+
- `startup_cost`: fixed cost incurred for starting a generator in an interval
45+
- `state0`: number of generators active before the first period
46+
47+
Input data for the generators is stored in a DataFrame with the generator class name as the index.
48+
49+
```{code-cell}
50+
# Load and check the generator data
51+
generator_data = pd.read_csv(
52+
"data/generators.csv",
53+
index_col="generator_class",
54+
)
55+
generator_data
56+
```
57+
58+
Each time period has the following data:
59+
60+
- `expected_demand`: predicted MWh demand, which the solution will meet exactly
61+
- `minimum_capacity`: value in MWh above the predicted demand; the total online generation capacity must exceed this value
62+
63+
Input data for the time periods is stored in a DataFrame with the time periods as the index.
64+
65+
```{code-cell}
66+
# Load and check the time period data
67+
time_period_data = pd.read_csv(
68+
"data/time_periods.csv",
69+
parse_dates=["time_period"],
70+
index_col="time_period",
71+
)
72+
time_period_data
73+
```
74+
75+
## Create the model
76+
77+
```{code-cell}
78+
model = gp.Model()
79+
```
80+
81+
## Add Time-Expanded Variables
82+
83+
The model has three variable types capturing the state of each generator class:
84+
85+
- `output`: The total output of all generators in the class in the given time period (continuous)
86+
- `num_active`: The number of active generators of the class in the given time period (integer, upper bounded by number of available generators)
87+
- `num_startup`: The number of active generators of the class which start up in the given time period (integer)
88+
89+
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](https://pandas.pydata.org/docs/reference/api/pandas.MultiIndex.from_product.html) method.
90+
91+
Using this time-expanded index, we'll then use the DataFrame accessors from gurobipy-pandas to create our variables.
92+
93+
```{code-cell}
94+
# Simplifies variable names
95+
short_time = {"time_period": lambda index: index.strftime("%H%M")}
96+
97+
# Construct time-expanded index and add variables
98+
generators = (
99+
# Create a new dataframe for the time-expanded index
100+
pd.DataFrame(
101+
index=pd.MultiIndex.from_product([generator_data.index, time_period_data.index])
102+
)
103+
.join(generator_data)
104+
# Create continuous variables (one per row) for generator output
105+
.gppd.add_vars(model, name="output", index_formatter=short_time)
106+
# Create integer variables for the number of active generators
107+
.gppd.add_vars(
108+
model,
109+
vtype=GRB.INTEGER,
110+
ub="num_available", # Use num_available from the input data as a bound
111+
name="num_active",
112+
index_formatter=short_time,
113+
)
114+
# Create non-negative integer variables capturing generator startups
115+
.gppd.add_vars(
116+
model, vtype=GRB.INTEGER, name="num_startup", index_formatter=short_time
117+
)
118+
)
119+
```
120+
121+
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.
122+
123+
```{code-cell}
124+
generators
125+
```
126+
127+
## Demand Constraints
128+
129+
There are two types of demand constraints:
130+
131+
1. The total output of all generators in each time period must match the expected demand
132+
2. The active generators in each time period must be able to meet the reserve demand
133+
134+
```{code-cell}
135+
# Constrain that predicted demand is exactly satisfied
136+
demand_constraint = gppd.add_constrs(
137+
model,
138+
generators.groupby("time_period")["output"].sum(),
139+
GRB.EQUAL,
140+
time_period_data["expected_demand"],
141+
index_formatter=short_time,
142+
)
143+
```
144+
145+
```{code-cell}
146+
# Constrain that the active generators during each time
147+
# period are capable of meeting the reserve demand.
148+
active_capacity = (
149+
(generators["max_output"] * generators["num_active"])
150+
.groupby("time_period").sum()
151+
).rename("active_capacity")
152+
active_capacity_constraint = gppd.add_constrs(
153+
model,
154+
active_capacity,
155+
GRB.GREATER_EQUAL,
156+
time_period_data["minimum_active_capacity"],
157+
index_formatter=short_time,
158+
)
159+
```
160+
161+
Note that we keep total online capacity as a series of expressions. This way we can directly use it in analysis of the results.
162+
163+
```{code-cell}
164+
active_capacity.to_frame()
165+
```
166+
167+
## Output Constraints
168+
169+
Each generator class is constrained within it's operating limits.
170+
171+
```{code-cell}
172+
df = (
173+
generators
174+
.gppd.add_constrs(
175+
model,
176+
"output >= min_output * num_active",
177+
name="lower_limit",
178+
index_formatter=short_time,
179+
)
180+
.gppd.add_constrs(
181+
model,
182+
"output <= max_output * num_active",
183+
name="constr_max_output",
184+
index_formatter=short_time,
185+
)
186+
)
187+
```
188+
189+
## Startup Constraints
190+
191+
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.
192+
193+
```{code-cell}
194+
# Constrain the relationship between active generators and startups.
195+
196+
def startup_constraints(group):
197+
group = group.sort_index()
198+
return gppd.add_constrs(
199+
model,
200+
group["num_startup"].iloc[1:],
201+
GRB.GREATER_EQUAL,
202+
group["num_active"].diff().dropna(),
203+
name="startup",
204+
index_formatter=short_time,
205+
)
206+
207+
startup = generators.groupby("generator_class").apply(startup_constraints).droplevel(0)
208+
```
209+
210+
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")`):
211+
212+
```
213+
startup[thermal1,0900]: num_active[thermal1,0800]
214+
- num_active[thermal1,0900] + num_startup[thermal1,0900] >= 0
215+
```
216+
217+
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.
218+
219+
```{code-cell}
220+
# Separately capture the startups at time period 0.
221+
222+
time_period_1 = generators.sort_index().groupby("generator_class").first()
223+
initial_startup = gppd.add_constrs(
224+
model,
225+
time_period_1["num_startup"],
226+
GRB.GREATER_EQUAL,
227+
time_period_1["num_active"] - generator_data["state0"],
228+
name="initial_startup",
229+
index_formatter=short_time,
230+
)
231+
```
232+
233+
## Objective Function
234+
235+
The total cost objective is now easy to compute:
236+
237+
```{code-cell}
238+
# Minimize total cost objective
239+
model.setObjective(
240+
(
241+
# Fixed hourly costs for started generators
242+
generators["cost_per_hour"] * generators["num_active"]
243+
# Marginal hourly cost of additional generation above the minimum
244+
+ generators["marginal_cost"]
245+
* (generators["output"] - generators["num_active"] * generators["min_output"])
246+
# Startup costs for newly active generators in each time period
247+
+ generators["startup_cost"] * generators["num_startup"]
248+
).sum(),
249+
sense=GRB.MINIMIZE
250+
)
251+
```
252+
253+
## Solve the model
254+
255+
```{code-cell}
256+
model.optimize()
257+
```
258+
259+
## Extract results
260+
261+
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.
262+
263+
```{code-cell}
264+
# Extract all variable values
265+
solution = pd.DataFrame(
266+
dict(
267+
output=generators["output"].gppd.X,
268+
num_active=generators["num_active"].gppd.X,
269+
num_startup=generators["num_startup"].gppd.X,
270+
)
271+
)
272+
273+
# Extract some additional results for comparison
274+
results = pd.DataFrame(
275+
{
276+
"Demand": time_period_data["expected_demand"],
277+
"Min. Active Capacity": time_period_data["minimum_active_capacity"],
278+
"Active Capacity": active_capacity.gppd.get_value(),
279+
"Excess": (-active_capacity_constraint.gppd.Slack),
280+
}
281+
)
282+
283+
# After extracting all results, close the model.
284+
model.close()
285+
```
286+
287+
## Analysis
288+
289+
Briefly; show the solution meeting the reserve demand and the excess capacity online.
290+
291+
```{code-cell}
292+
%matplotlib inline
293+
%config InlineBackend.figure_formats = ['svg']
294+
295+
import matplotlib
296+
import matplotlib.pyplot as plt
297+
import seaborn as sns
298+
299+
sns.set_palette(sns.color_palette("deep"))
300+
301+
plt.figure(figsize=(8, 4))
302+
results.plot.line(ax=plt.gca())
303+
plt.xlabel("Time")
304+
plt.ylabel("MWh")
305+
plt.ylim([0, 50000])
306+
plt.legend(loc=2);
307+
```

0 commit comments

Comments
 (0)