Economic Dispatch and Unit Commitment#
Modern power grids rely on sophisticated optimization algorithms to balance electricity supply and demand while maintaining reliability and minimizing costs. This lesson explores practical optimization techniques that grid operators use every day, from economic dispatch to handling renewable energy uncertainty.
We’ll build increasingly complex models that capture real-world operational challenges. Each section includes working examples followed by exercises that deepen your understanding. The modular code structure demonstrates how these optimization patterns appear across different power system problems.
Utility Functions#
We begin by defining reusable functions for data generation, optimization setup, visualization, and results analysis. These utilities eliminate code duplication and let us focus on the unique aspects of each optimization problem.
1. Introduction to Power System Optimization#
Power system optimization determines how to operate the grid most efficiently while maintaining reliability. At its core, this means deciding which generators to run and at what output levels. Let’s start with a simple example that demonstrates the fundamental concepts.
# Simple Economic Dispatch Example
generators = create_generator_data(n_units=3)
demand = 250 # MW
print("Generator Data:")
print(generators)
# Create and solve ED problem
prob = LpProblem("Simple_ED", LpMinimize)
# Decision variables
p = {g: LpVariable(f"p_{g}", 0) for g in generators.index}
# Objective: minimize cost
prob += lpSum(generators.loc[g, 'cost'] * p[g] for g in generators.index)
# Constraints
add_power_balance(prob, p, demand)
add_generator_limits(prob, p, generators)
# Solve and display results
results = solve_and_extract_results(prob, p)
print_optimization_results(results, "Simple Economic Dispatch")
plot_generation_dispatch(results, generators, demand);
Generator Data:
p_min p_max cost
G1 20.0 100.0 20.0
G2 60.0 250.0 27.5
G3 100.0 400.0 35.0
Simple Economic Dispatch
========================
Status: Optimal
Total cost: $6950.00
LMP: $20.00/MWh
Generation dispatch:
G1: 90.0 MW
G2: 60.0 MW
G3: 100.0 MW

2. Advanced Economic Dispatch#
Real generators have more complex cost characteristics than simple linear functions. Piecewise linear costs better represent efficiency variations across the operating range, while prohibited zones capture mechanical limitations that prevent operation at certain power levels.
Example: Piecewise Linear Costs#
Many generators become less efficient at higher output levels, leading to increasing marginal costs. We model this with piecewise linear segments where each segment has a different cost slope.
When generators operate at discontinuity points between segments, the marginal cost becomes undefined, creating challenges for market pricing. The example below demonstrates how one generator operating within a segment provides a clear marginal price signal while another at a discontinuity point has an undefined marginal cost.
def solve_piecewise_ed(demand=265):
"""Economic dispatch with piecewise linear costs."""
# Define generators with cost segments
piecewise_data = {
'G1': {
'segments': [(50, 100, 20), (100, 150, 22), (150, 200, 25)], # (start, end, cost)
},
'G2': {
'segments': [(40, 100, 24), (100, 150, 28)],
}
}
prob = LpProblem("Piecewise_ED", LpMinimize)
# Variables for each segment
p_seg = {}
for g, data in piecewise_data.items():
for i, (start, end, cost) in enumerate(data['segments']):
p_seg[g, i] = LpVariable(f"p_{g}_{i}", 0, end - start)
# Binary variables to indicate if a segment is used
z_seg = {}
for g, data in piecewise_data.items():
for i in range(len(data['segments'])):
z_seg[g, i] = LpVariable(f"z_{g}_{i}", cat='Binary')
# Total generation per unit (actual power output)
p_total = {}
for g in piecewise_data:
# Actual generation = minimum + sum of segment increments
min_gen = piecewise_data[g]['segments'][0][0]
p_total[g] = min_gen + lpSum(p_seg[g, i]
for i in range(len(piecewise_data[g]['segments'])))
# Objective
prob += lpSum(
piecewise_data[g]['segments'][i][2] * p_seg[g, i]
for g in piecewise_data
for i in range(len(piecewise_data[g]['segments']))
)
# Constraints
prob += lpSum(p_total[g] for g in piecewise_data) == demand, "Power_Balance"
# Enforce piecewise structure: segments must be filled in order
for g in piecewise_data:
# First segment can always be used
if len(piecewise_data[g]['segments']) > 0:
start, end, _ = piecewise_data[g]['segments'][0]
prob += p_seg[g, 0] <= (end - start) * z_seg[g, 0]
# Subsequent segments
for i in range(1, len(piecewise_data[g]['segments'])):
start, end, _ = piecewise_data[g]['segments'][i]
start_prev, end_prev, _ = piecewise_data[g]['segments'][i-1]
# Segment i can only be used if segment i-1 is full
prob += z_seg[g, i] <= z_seg[g, i-1]
prob += p_seg[g, i] <= (end - start) * z_seg[g, i]
prob += p_seg[g, i-1] >= (end_prev - start_prev) * z_seg[g, i] - 0.001
prob.solve(PULP_CBC_CMD(msg=0))
# Extract results
results = {
'status': LpStatus[prob.status],
'cost': value(prob.objective),
'generation': {g: value(p_total[g]) for g in piecewise_data},
'lmp': prob.constraints['Power_Balance'].pi
}
return results
# Solve and visualize
results = solve_piecewise_ed(265)
print_optimization_results(results, "Piecewise Linear Economic Dispatch")
Piecewise Linear Economic Dispatch
==================================
Status: Optimal
Total cost: $3915.00
LMP: $25.00/MWh
Generation dispatch:
G1: 165.0 MW
G2: 100.0 MW

Exercise 2.1: Economic Dispatch with Prohibited Zones#
Some generators cannot operate in certain power ranges due to mechanical vibrations or other technical constraints. These prohibited operating zones create a more complex optimization problem that requires binary variables to ensure feasible solutions.
Implement economic dispatch for three generators where G1 cannot operate between 75-85 MW and G2 cannot operate between 110-120 MW. The total system demand is 300 MW. Your solution should use binary variables to select which operating zone each generator uses, ensuring no generator operates in its prohibited range.
Hint: For each generator with prohibited zones, create separate continuous variables for each allowable operating range. Use binary variables to ensure only one range is active. The sum of all zone outputs equals the generator’s total output.
# Exercise 2.1: Your implementation here
def ed_with_prohibited_zones(demand=300):
# Define generator data
generators = {
'G1': {'p_min': 50, 'p_max': 150, 'cost': 22, 'prohibited': [(75, 85)]},
'G2': {'p_min': 40, 'p_max': 180, 'cost': 25, 'prohibited': [(110, 120)]},
'G3': {'p_min': 30, 'p_max': 120, 'cost': 30, 'prohibited': []}
}
# Your solution here
pass
3. Unit Commitment#
Unit commitment extends economic dispatch by determining not just how much each generator produces, but also when to start up or shut down units. This mixed-integer optimization problem balances operating costs against startup costs while respecting technical constraints like minimum run times.
Let’s start with a simplified example before tackling the full formulation.
Example: Simple Unit Commitment#
We’ll solve unit commitment for a small system over 6 hours, focusing on the core concepts of binary commitment decisions and startup costs.
def simple_unit_commitment():
"""Simplified UC for 3 units over 8 hours with clear on/off behavior."""
# Create more distinctive generator characteristics
generators = pd.DataFrame({
'p_min': [100, 30, 20], # G1 has high minimum (base load)
'p_max': [200, 150, 80], # Different capacities
'cost': [10, 25, 50], # Significant cost differences
'startup_cost': [500, 2000, 1000], # G2 has high startup cost
'initial_status': [1, 0, 0], # Only G1 starts on
'initial_power': [150, 0, 0]
}, index=['G1_Base', 'G2_Mid', 'G3_Peak'])
hours = 8
# Create demand profile that forces unit cycling
demand = np.array([120, 130, 180, 250, 360, 350, 200, 140])
# Display data
print("Generator Data:")
print(generators[['p_min', 'p_max', 'cost', 'startup_cost']])
print(f"\nDemand: {demand}")
# Create problem
prob = LpProblem("Simple_UC", LpMinimize)
periods = range(hours)
# Variables
u = {} # Unit on/off
p = {} # Power output
v = {} # Startup indicator
for g in generators.index:
for t in periods:
u[g,t] = LpVariable(f"u_{g}_{t}", cat='Binary')
p[g,t] = LpVariable(f"p_{g}_{t}", 0)
v[g,t] = LpVariable(f"v_{g}_{t}", cat='Binary')
# Objective
operating_cost = lpSum(generators.loc[g, 'cost'] * p[g,t]
for g in generators.index for t in periods)
startup_cost = lpSum(generators.loc[g, 'startup_cost'] * v[g,t]
for g in generators.index for t in periods)
prob += operating_cost + startup_cost
# Constraints
for t in periods:
# Power balance
prob += lpSum(p[g,t] for g in generators.index) == demand[t]
# Reserve (simplified - 5% of demand)
prob += lpSum(generators.loc[g, 'p_max'] * u[g,t]
for g in generators.index) >= 1.05 * demand[t]
for g in generators.index:
for t in periods:
# Generation limits
prob += p[g,t] >= generators.loc[g, 'p_min'] * u[g,t]
prob += p[g,t] <= generators.loc[g, 'p_max'] * u[g,t]
# Startup logic
if t == 0:
prob += v[g,t] >= u[g,t] - generators.loc[g, 'initial_status']
else:
prob += v[g,t] >= u[g,t] - u[g,t-1]
# Solve
prob.solve(PULP_CBC_CMD(msg=0))
# Extract results
schedule = pd.DataFrame(index=generators.index, columns=periods)
generation = pd.DataFrame(index=generators.index, columns=periods)
for g in generators.index:
for t in periods:
schedule.loc[g, t] = int(value(u[g,t]))
generation.loc[g, t] = value(p[g,t]) if schedule.loc[g, t] else 0
return schedule, generation, demand, value(prob.objective)
Generator Data:
p_min p_max cost startup_cost
G1_Base 100 200 10 500
G2_Mid 30 150 25 2000
G3_Peak 20 80 50 1000
Demand: [120 130 180 250 360 350 200 140]
Total cost: $27,150
Unit Schedule (1=ON, 0=OFF):
0 1 2 3 4 5 6 7
G1_Base 1 1 1 1 1 1 1 1
G2_Mid 0 0 0 1 1 1 1 0
G3_Peak 0 0 0 0 1 1 0 0
Startup events:
G2_Mid: hours [3]
G3_Peak: hours [4]
Shutdown events:
G2_Mid: hours [7]
G3_Peak: hours [6]

Exercise 3.1: Full Unit Commitment with Technical Constraints#
Real unit commitment problems include additional technical constraints that ensure feasible generator operation. Minimum up and down times prevent excessive cycling that would damage equipment, while ramping limits restrict how quickly generators can change output.
Extend the simple unit commitment to include minimum up/down time constraints and ramping limits for a 24-hour period. Use the provided generator data which includes these technical parameters. Your formulation should ensure that once a unit starts up, it remains on for at least its minimum up time, and similarly for shutdowns.
Hint: For minimum up time of 4 hours, if a unit starts at hour t, it must remain on through hour t+3. Use the startup variable v[g,t] to trigger these constraints. Ramping constraints limit the change in output between consecutive hours but must account for startup and shutdown transitions.
# Exercise 3.1: Your implementation here
def full_unit_commitment():
# Load data
tot_hours = 24
generators = create_generator_data(n_units=3, include_startup=True)
demand = create_demand_profile(hours=tot_hours)
# Display the data
plot_demand_profile(demand)
plt.show()
print("Generator Data:")
print(generators)
# Your implementation here
pass
Summary#
This lesson demonstrated how optimization techniques solve critical power system operational challenges. We progressed from basic economic dispatch through unit commitment with technical constraints. Those optimization models are run daily in numerous system operators.
Next, we will discuss security-constrained optimization for reliability, optimal power flow formulations, and renewable integration.