Optimal Power Flow (OPF)

Minimizing Generation Cost While Balancing Supply and Demand

The Optimal Power Flow (OPF) problem is one of the most fundamental optimization problems in power systems engineering. The goal is simple to state: find the cheapest combination of generator outputs that satisfies electricity demand, while respecting the physical and operational limits of the network. In practice, OPF underlies economic dispatch, unit commitment, and real-time energy market clearing in every modern power grid.

This article walks through a concrete five-bus example, solves it using Python’s scipy.optimize, and visualizes the results in both 2D and 3D.


Problem Formulation

Consider a power network with $N_b$ buses and $N_g$ generators. Each generator $i$ has a quadratic cost function

$$C_i(P_i) = a_i P_i^2 + b_i P_i + c_i \quad [$/\text{h}]$$

where $P_i$ is the real power output in MW.

Objective

Minimize total generation cost:

$$\min_{P_1, \ldots, P_{N_g}} \sum_{i=1}^{N_g} C_i(P_i) = \sum_{i=1}^{N_g} \left( a_i P_i^2 + b_i P_i + c_i \right)$$

Constraints

1. Power balance (equality constraint)

Total generation must equal total load plus transmission losses. In a lossless DC approximation:

$$\sum_{i=1}^{N_g} P_i = P_D \quad \text{(MW)}$$

where $P_D$ is the total system demand.

2. Generator capacity limits (inequality constraints)

$$P_i^{\min} \leq P_i \leq P_i^{\max}, \quad \forall i$$

3. DC power flow (line flow limits)

For each transmission line $\ell$ connecting bus $m$ to bus $n$, the power flow is given by:

$$P_\ell = \frac{\theta_m - \theta_n}{x_\ell}$$

where $x_\ell$ is the line reactance and $\theta_m, \theta_n$ are voltage angles. Line flows must satisfy:

$$|P_\ell| \leq P_\ell^{\max}$$

The DC power flow equations in matrix form are:

$$\mathbf{B} \boldsymbol{\theta} = \mathbf{P}_{\text{inj}}$$

where $\mathbf{B}$ is the bus susceptance matrix and $\mathbf{P}_{\text{inj}}$ is the net power injection vector.


Example System: 5-Bus Network

The test case uses a 5-bus system with 3 generators and 6 transmission lines — a classic small-scale benchmark.

1
2
3
4
5
Bus 1 (Gen 1) ---L12--- Bus 2 (Gen 2) ---L23--- Bus 3 (Load)
| | |
L14 L24 L35
| | |
Bus 4 (Load) ----------L45---------- Bus 5 (Gen 3, Load)

Generator parameters:

Generator Bus $a_i$ $b_i$ $c_i$ $P^{\min}$ (MW) $P^{\max}$ (MW)
G1 1 0.004 2.0 80 10 200
G2 2 0.006 1.8 60 10 150
G3 5 0.009 2.2 40 10 100

Load: Bus 3 = 90 MW, Bus 4 = 80 MW, Bus 5 = 50 MW → Total $P_D = 220$ MW

Line data (reactance $x_\ell$, limit $P^{\max}_\ell$):

Line From To $x$ (p.u.) $P^{\max}$ (MW)
L12 1 2 0.10 150
L14 1 4 0.15 100
L23 2 3 0.12 100
L24 2 4 0.18 80
L35 3 5 0.20 60
L45 4 5 0.25 60

Python Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
# ============================================================
# Optimal Power Flow (OPF) - 5-Bus DC OPF Example
# Solver: scipy.optimize.minimize (SLSQP)
# Visualization: matplotlib (2D + 3D)
# ============================================================

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from matplotlib import cm
from scipy.optimize import minimize, LinearConstraint, Bounds
from mpl_toolkits.mplot3d import Axes3D
import warnings
warnings.filterwarnings("ignore")

# ── dark theme ────────────────────────────────────────────────
plt.style.use("dark_background")
DARK_BG = "#0d1117"
PANEL_BG = "#161b22"
ACCENT = "#58a6ff"
ACCENT2 = "#3fb950"
ACCENT3 = "#f78166"
ACCENT4 = "#d2a8ff"
GOLD = "#e3b341"
TEXT_CLR = "#c9d1d9"
GRID_CLR = "#21262d"

plt.rcParams.update({
"figure.facecolor": DARK_BG,
"axes.facecolor": PANEL_BG,
"axes.edgecolor": "#30363d",
"axes.labelcolor": TEXT_CLR,
"xtick.color": TEXT_CLR,
"ytick.color": TEXT_CLR,
"text.color": TEXT_CLR,
"grid.color": GRID_CLR,
"grid.linestyle": "--",
"grid.alpha": 0.5,
"legend.framealpha": 0.2,
"legend.edgecolor": "#30363d",
"font.size": 11,
})

# ═══════════════════════════════════════════════════════════════
# 1. SYSTEM DATA
# ═══════════════════════════════════════════════════════════════

N_BUS = 5 # number of buses
N_GEN = 3 # number of generators
SLACK = 0 # slack bus index (0-based)

# Generator cost coefficients: a*P^2 + b*P + c
gen_a = np.array([0.004, 0.006, 0.009]) # $/MW^2/h
gen_b = np.array([2.0, 1.8, 2.2 ]) # $/MWh
gen_c = np.array([80.0, 60.0, 40.0 ]) # $/h (no-load cost)

# Generator capacity limits [MW]
gen_pmin = np.array([10.0, 10.0, 10.0])
gen_pmax = np.array([200.0, 150.0, 100.0])

# Generator bus assignment (0-based)
gen_bus = np.array([0, 1, 4])

# Load at each bus [MW] (0-based, index = bus number - 1)
load_bus = np.zeros(N_BUS)
load_bus[2] = 90.0 # Bus 3
load_bus[3] = 80.0 # Bus 4
load_bus[4] = 50.0 # Bus 5
P_demand = load_bus.sum() # 220 MW

# Line data: [from_bus, to_bus, reactance (p.u.), max_flow (MW)]
lines = [
(0, 1, 0.10, 150.0), # L12
(0, 3, 0.15, 100.0), # L14
(1, 2, 0.12, 100.0), # L23
(1, 3, 0.18, 80.0), # L24
(2, 4, 0.20, 60.0), # L35
(3, 4, 0.25, 60.0), # L45
]
N_LINE = len(lines)
line_labels = ["L12", "L14", "L23", "L24", "L35", "L45"]

# ═══════════════════════════════════════════════════════════════
# 2. DC POWER FLOW MATRICES
# ═══════════════════════════════════════════════════════════════

def build_B_matrix(n_bus, lines):
"""Build full susceptance matrix B (n_bus x n_bus)."""
B = np.zeros((n_bus, n_bus))
for (fr, to, x, _) in lines:
b = 1.0 / x
B[fr, fr] += b
B[to, to] += b
B[fr, to] -= b
B[to, fr] -= b
return B

def dc_power_flow(P_gen, gen_bus, load_bus, lines, n_bus, slack=0):
"""
Solve DC OPF power flow.
Returns voltage angles (rad) and line flows (MW).
"""
# Net injection at each bus
P_inj = np.zeros(n_bus)
for i, g in enumerate(gen_bus):
P_inj[g] += P_gen[i]
P_inj -= load_bus

B = build_B_matrix(n_bus, lines)

# Remove slack row/column, solve for non-slack angles
non_slack = [i for i in range(n_bus) if i != slack]
B_red = B[np.ix_(non_slack, non_slack)]
P_red = P_inj[non_slack]

theta_red = np.linalg.solve(B_red, P_red)

theta = np.zeros(n_bus)
for k, idx in enumerate(non_slack):
theta[idx] = theta_red[k]

# Line flows
flows = np.zeros(len(lines))
for k, (fr, to, x, _) in enumerate(lines):
flows[k] = (theta[fr] - theta[to]) / x # p.u. → MW (already in MW since B uses 1/x with MW injections)

return theta, flows

# ═══════════════════════════════════════════════════════════════
# 3. OPF FORMULATION
# ═══════════════════════════════════════════════════════════════

def total_cost(P_gen):
"""Quadratic total generation cost [$/h]."""
return np.sum(gen_a * P_gen**2 + gen_b * P_gen + gen_c)

def total_cost_grad(P_gen):
"""Analytic gradient of cost."""
return 2.0 * gen_a * P_gen + gen_b

# ── Constraints ───────────────────────────────────────────────

def power_balance(P_gen):
"""Equality: sum of generation = total demand."""
return P_gen.sum() - P_demand

def line_flow_constraints(P_gen):
"""
Inequality constraints: -P_max <= flow <= P_max
Returns array that must be >= 0 for scipy (feasible side).
"""
_, flows = dc_power_flow(P_gen, gen_bus, load_bus, lines, N_BUS, SLACK)
c = []
for k, (fr, to, x, fmax) in enumerate(lines):
c.append(fmax - flows[k]) # flow <= fmax
c.append(fmax + flows[k]) # flow >= -fmax
return np.array(c)

constraints = [
{"type": "eq", "fun": power_balance},
{"type": "ineq", "fun": line_flow_constraints},
]

bounds = Bounds(gen_pmin, gen_pmax)

# ── Initial point: proportional dispatch ─────────────────────
P_total_cap = gen_pmax.sum()
P0 = gen_pmin + (gen_pmax - gen_pmin) * (P_demand - gen_pmin.sum()) / (
(gen_pmax - gen_pmin).sum())
P0 = np.clip(P0, gen_pmin, gen_pmax)

# ═══════════════════════════════════════════════════════════════
# 4. SOLVE OPF
# ═══════════════════════════════════════════════════════════════

result = minimize(
total_cost,
P0,
jac=total_cost_grad,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-9, "maxiter": 500, "disp": False},
)

P_opt = result.x
cost_opt = result.fun
theta_opt, flows_opt = dc_power_flow(P_opt, gen_bus, load_bus, lines, N_BUS, SLACK)

print("=" * 55)
print(" OPF Solution (SLSQP)")
print("=" * 55)
print(f" Converged : {result.success} ({result.message})")
print(f" Total demand : {P_demand:.1f} MW")
print(f" Total gen : {P_opt.sum():.4f} MW")
print()
print(" Generator Dispatch:")
for i in range(N_GEN):
pct = (P_opt[i] - gen_pmin[i]) / (gen_pmax[i] - gen_pmin[i]) * 100
ci = gen_a[i]*P_opt[i]**2 + gen_b[i]*P_opt[i] + gen_c[i]
print(f" G{i+1} (Bus {gen_bus[i]+1}): {P_opt[i]:7.3f} MW "
f"[{gen_pmin[i]:.0f}{gen_pmax[i]:.0f}] "
f"Load: {pct:.1f}% Cost: {ci:.2f} $/h")
print()
print(f" Total cost : {cost_opt:.4f} $/h")
print()
print(" Line Flows:")
for k, (fr, to, x, fmax) in enumerate(lines):
util = abs(flows_opt[k]) / fmax * 100
flag = " *** CONGESTED ***" if util > 99.0 else ""
print(f" {line_labels[k]} ({fr+1}{to+1}): {flows_opt[k]:+8.3f} MW"
f" / {fmax:.0f} MW ({util:.1f}%){flag}")
print()
print(" Voltage Angles (degrees):")
for i in range(N_BUS):
print(f" Bus {i+1}: {np.degrees(theta_opt[i]):+8.4f}°")
print("=" * 55)

# ═══════════════════════════════════════════════════════════════
# 5. VISUALIZATION
# ═══════════════════════════════════════════════════════════════

fig = plt.figure(figsize=(18, 14), facecolor=DARK_BG)
fig.suptitle("Optimal Power Flow — 5-Bus DC OPF Results",
fontsize=16, color=TEXT_CLR, y=0.98, fontweight="bold")

gs = gridspec.GridSpec(3, 3, figure=fig,
hspace=0.48, wspace=0.38,
left=0.06, right=0.97, top=0.93, bottom=0.05)

gen_colors = [ACCENT, ACCENT2, ACCENT3]
gen_names = ["G1 (Bus 1)", "G2 (Bus 2)", "G3 (Bus 5)"]

# ── Plot 1: Generator dispatch bar chart ──────────────────────
ax1 = fig.add_subplot(gs[0, 0])
x_pos = np.arange(N_GEN)
bars = ax1.bar(x_pos, P_opt, color=gen_colors, width=0.5,
edgecolor="#30363d", linewidth=0.8, zorder=3)
# Capacity limit markers
for i in range(N_GEN):
ax1.hlines(gen_pmax[i], i - 0.35, i + 0.35,
colors=GOLD, linewidths=1.5, linestyles="--", zorder=4)
ax1.hlines(gen_pmin[i], i - 0.35, i + 0.35,
colors=TEXT_CLR, linewidths=1.0, linestyles=":", alpha=0.6, zorder=4)
ax1.text(i, P_opt[i] + 3, f"{P_opt[i]:.1f}", ha="center",
fontsize=10, color=TEXT_CLR, fontweight="bold")
ax1.set_xticks(x_pos)
ax1.set_xticklabels(gen_names, fontsize=9)
ax1.set_ylabel("Power (MW)")
ax1.set_title("Generator Dispatch", color=TEXT_CLR)
ax1.set_ylim(0, 220)
ax1.grid(True, axis="y")
ax1.legend(["Optimal output", "P_max limit", "P_min limit"],
loc="upper right", fontsize=8,
handles=[
plt.Rectangle((0,0),1,1, color=ACCENT, alpha=0.8),
plt.Line2D([0],[0], color=GOLD, lw=1.5, ls="--"),
plt.Line2D([0],[0], color=TEXT_CLR, lw=1.0, ls=":", alpha=0.6),
])

# ── Plot 2: Cost breakdown pie ────────────────────────────────
ax2 = fig.add_subplot(gs[0, 1])
costs_ind = [gen_a[i]*P_opt[i]**2 + gen_b[i]*P_opt[i] + gen_c[i]
for i in range(N_GEN)]
wedges, texts, autotexts = ax2.pie(
costs_ind,
labels=gen_names,
autopct="%1.1f%%",
colors=gen_colors,
startangle=90,
wedgeprops={"edgecolor": DARK_BG, "linewidth": 1.5},
)
for at in autotexts:
at.set_fontsize(9)
at.set_color(DARK_BG)
ax2.set_title(f"Cost Share (Total: {cost_opt:.1f} $/h)", color=TEXT_CLR)

# ── Plot 3: Line utilization bars ─────────────────────────────
ax3 = fig.add_subplot(gs[0, 2])
util_pct = np.abs(flows_opt) / np.array([l[3] for l in lines]) * 100
bar_colors = [ACCENT3 if u > 90 else ACCENT2 if u > 60 else ACCENT
for u in util_pct]
ax3.barh(line_labels, util_pct, color=bar_colors, edgecolor="#30363d",
linewidth=0.8, zorder=3)
ax3.axvline(100, color=GOLD, lw=1.5, ls="--", label="Limit (100%)")
ax3.axvline(90, color=ACCENT3, lw=1.0, ls=":", alpha=0.7, label="90% warning")
ax3.set_xlabel("Utilization (%)")
ax3.set_title("Line Flow Utilization", color=TEXT_CLR)
ax3.set_xlim(0, 115)
ax3.grid(True, axis="x")
ax3.legend(fontsize=8)
for i, (u, f) in enumerate(zip(util_pct, flows_opt)):
ax3.text(u + 0.5, i, f"{f:+.1f} MW", va="center", fontsize=8, color=TEXT_CLR)

# ── Plot 4: Network topology diagram ──────────────────────────
ax4 = fig.add_subplot(gs[1, 0:2])
ax4.set_facecolor(PANEL_BG)
ax4.set_xlim(-0.3, 4.3)
ax4.set_ylim(-0.5, 2.2)
ax4.axis("off")
ax4.set_title("Network Topology & Power Flows", color=TEXT_CLR, pad=8)

bus_pos = {
0: (0.0, 1.5), # Bus 1
1: (2.0, 2.0), # Bus 2
2: (4.0, 1.8), # Bus 3
3: (0.5, 0.2), # Bus 4
4: (3.5, 0.2), # Bus 5
}

# Draw lines
line_colors_topo = []
for k, (fr, to, x, fmax) in enumerate(lines):
u = abs(flows_opt[k]) / fmax
c = ACCENT3 if u > 0.9 else ACCENT2 if u > 0.6 else ACCENT
line_colors_topo.append(c)
xf, yf = bus_pos[fr]
xt, yt = bus_pos[to]
ax4.plot([xf, xt], [yf, yt], color=c, lw=2.5 + u * 2.5,
alpha=0.8, zorder=1)
mx, my = (xf + xt) / 2, (yf + yt) / 2
ax4.text(mx, my + 0.08, f"{flows_opt[k]:+.1f}", ha="center",
fontsize=8, color=c, fontweight="bold",
bbox={"boxstyle": "round,pad=0.2", "facecolor": PANEL_BG,
"edgecolor": c, "alpha": 0.85})

# Draw buses
for bus_idx, (bx, by) in bus_pos.items():
is_gen = bus_idx in gen_bus
is_load = load_bus[bus_idx] > 0
radius = 0.22 if is_gen else 0.16
fc = ACCENT if is_gen else ACCENT3 if is_load else TEXT_CLR

circ = plt.Circle((bx, by), radius, color=fc, zorder=3, alpha=0.9)
ax4.add_patch(circ)
ax4.text(bx, by, f"B{bus_idx+1}", ha="center", va="center",
fontsize=9, fontweight="bold", color=DARK_BG, zorder=4)

# Annotation
lines_ann = [f"Bus {bus_idx+1}"]
gen_idx = np.where(gen_bus == bus_idx)[0]
if len(gen_idx) > 0:
gi = gen_idx[0]
lines_ann.append(f"G{gi+1}: {P_opt[gi]:.1f} MW")
if load_bus[bus_idx] > 0:
lines_ann.append(f"Load: {load_bus[bus_idx]:.0f} MW")
offset = (0.0, 0.32) if by > 1.0 else (0.0, -0.32)
ax4.text(bx + offset[0], by + offset[1],
"\n".join(lines_ann), ha="center", va="center",
fontsize=8, color=TEXT_CLR,
bbox={"boxstyle": "round,pad=0.3", "facecolor": PANEL_BG,
"edgecolor": "#30363d", "alpha": 0.85})

ax4.legend(
handles=[
plt.Line2D([0],[0], color=ACCENT3, lw=3, label=">90% util."),
plt.Line2D([0],[0], color=ACCENT2, lw=3, label="60–90% util."),
plt.Line2D([0],[0], color=ACCENT, lw=3, label="<60% util."),
plt.Circle((0,0), 0.1, color=ACCENT, label="Generator bus"),
plt.Circle((0,0), 0.1, color=ACCENT3, label="Load bus"),
],
loc="lower right", fontsize=8, framealpha=0.3,
)

# ── Plot 5: Cost curve with operating point ───────────────────
ax5 = fig.add_subplot(gs[1, 2])
P_range = [np.linspace(gen_pmin[i], gen_pmax[i], 200) for i in range(N_GEN)]
for i in range(N_GEN):
c_curve = gen_a[i] * P_range[i]**2 + gen_b[i] * P_range[i] + gen_c[i]
ax5.plot(P_range[i], c_curve, color=gen_colors[i], lw=2, label=gen_names[i])
c_opt_i = gen_a[i] * P_opt[i]**2 + gen_b[i] * P_opt[i] + gen_c[i]
ax5.scatter(P_opt[i], c_opt_i, color=gen_colors[i],
s=90, zorder=5, edgecolors=GOLD, linewidths=1.5)
ax5.set_xlabel("Output P (MW)")
ax5.set_ylabel("Cost ($/h)")
ax5.set_title("Generator Cost Curves", color=TEXT_CLR)
ax5.legend(fontsize=8)
ax5.grid(True)

# ── Plot 6: Marginal cost (lambda) analysis ───────────────────
ax6 = fig.add_subplot(gs[2, 0])
lam = 2.0 * gen_a * P_opt + gen_b # marginal cost = dC/dP
bars_lam = ax6.bar(x_pos, lam, color=gen_colors, width=0.5,
edgecolor="#30363d", linewidth=0.8, zorder=3)
lam_mean = np.average(lam, weights=P_opt)
ax6.axhline(lam_mean, color=GOLD, lw=2, ls="--",
label=f"Avg λ = {lam_mean:.3f} $/MWh")
for i in range(N_GEN):
ax6.text(i, lam[i] + 0.01, f"{lam[i]:.3f}", ha="center",
fontsize=10, color=TEXT_CLR, fontweight="bold")
ax6.set_xticks(x_pos)
ax6.set_xticklabels(gen_names, fontsize=9)
ax6.set_ylabel("Marginal Cost ($/MWh)")
ax6.set_title("Incremental Cost (λ)", color=TEXT_CLR)
ax6.legend(fontsize=9)
ax6.grid(True, axis="y")

# ── Plot 7: Load-cost sensitivity sweep ──────────────────────
ax7 = fig.add_subplot(gs[2, 1])
demands = np.linspace(40, 420, 120)
costs_sweep = []
g1_sweep = []
g2_sweep = []
g3_sweep = []

for pd in demands:
def pb_sweep(Pg): return Pg.sum() - pd
def lfc_sweep(Pg):
_, fl = dc_power_flow(Pg, gen_bus, load_bus * pd / P_demand,
lines, N_BUS, SLACK)
c = []
for kk, (_, _, _, fm) in enumerate(lines):
c.append(fm - fl[kk])
c.append(fm + fl[kk])
return np.array(c)
con_s = [
{"type": "eq", "fun": pb_sweep},
{"type": "ineq", "fun": lfc_sweep},
]
p0_s = np.clip(gen_pmin + (gen_pmax - gen_pmin) * pd / P_total_cap,
gen_pmin, gen_pmax)
r_s = minimize(total_cost, p0_s, jac=total_cost_grad,
method="SLSQP", bounds=bounds, constraints=con_s,
options={"ftol": 1e-8, "maxiter": 300, "disp": False})
if r_s.success:
costs_sweep.append(r_s.fun)
g1_sweep.append(r_s.x[0])
g2_sweep.append(r_s.x[1])
g3_sweep.append(r_s.x[2])
else:
costs_sweep.append(np.nan)
g1_sweep.append(np.nan)
g2_sweep.append(np.nan)
g3_sweep.append(np.nan)

costs_sweep = np.array(costs_sweep)
ax7.plot(demands, costs_sweep / 1000, color=GOLD, lw=2.5, label="Total cost")
ax7.axvline(P_demand, color=ACCENT, lw=1.5, ls="--",
label=f"Operating point ({P_demand} MW)")
ax7.set_xlabel("Total Demand (MW)")
ax7.set_ylabel("Optimal Cost (k$/h)")
ax7.set_title("Cost vs. Total Demand", color=TEXT_CLR)
ax7.legend(fontsize=9)
ax7.grid(True)

# ── Plot 8: 3D — Cost surface G1 vs G2 ───────────────────────
ax8 = fig.add_subplot(gs[2, 2], projection="3d")

P1_grid = np.linspace(gen_pmin[0], gen_pmax[0], 60)
P2_grid = np.linspace(gen_pmin[1], gen_pmax[1], 60)
PP1, PP2 = np.meshgrid(P1_grid, P2_grid)
# Remaining load on G3 (clamped to feasible)
PP3 = np.clip(P_demand - PP1 - PP2, gen_pmin[2], gen_pmax[2])
CTOTAL = (gen_a[0]*PP1**2 + gen_b[0]*PP1 + gen_c[0] +
gen_a[1]*PP2**2 + gen_b[1]*PP2 + gen_c[1] +
gen_a[2]*PP3**2 + gen_b[2]*PP3 + gen_c[2])
# Mask infeasible (balance not met)
mask = np.abs(PP1 + PP2 + PP3 - P_demand) > 5.0
CTOTAL[mask] = np.nan

surf = ax8.plot_surface(PP1, PP2, CTOTAL, cmap="plasma",
alpha=0.85, linewidth=0, antialiased=True)

# Optimal point
ax8.scatter([P_opt[0]], [P_opt[1]], [cost_opt],
color=GOLD, s=120, zorder=10, depthshade=False,
edgecolors="white", linewidths=1.5, label="Optimal")

ax8.set_xlabel("G1 (MW)", labelpad=6)
ax8.set_ylabel("G2 (MW)", labelpad=6)
ax8.set_zlabel("Total Cost ($/h)", labelpad=6)
ax8.set_title("Cost Surface\n(G1 vs G2, G3 residual)", color=TEXT_CLR, pad=4)
ax8.legend(fontsize=9, loc="upper left")
ax8.tick_params(axis="both", which="major", labelsize=8)
ax8.set_facecolor(PANEL_BG)
fig.colorbar(surf, ax=ax8, shrink=0.45, pad=0.1,
label="Cost ($/h)")

plt.savefig("opf_results.png", dpi=150, bbox_inches="tight",
facecolor=DARK_BG)
plt.show()
print("\nFigure saved as opf_results.png")

Code Walkthrough

Section 1 — System Data

All network parameters are defined as NumPy arrays. The cost coefficients gen_a, gen_b, gen_c define the quadratic $C_i(P_i)$ for each generator. The lines list stores tuples (from_bus, to_bus, reactance, max_flow) for every branch.

Section 2 — DC Power Flow

build_B_matrix constructs the nodal susceptance matrix $\mathbf{B}$ by accumulating $1/x_\ell$ contributions on diagonal entries and $-1/x_\ell$ on off-diagonals — the standard admittance assembly. dc_power_flow removes the slack row and column, solves the reduced linear system $\mathbf{B}{\text{red}}\boldsymbol{\theta} = \mathbf{P}{\text{inj}}$ via np.linalg.solve, and recovers line flows from voltage angle differences:

$$P_\ell = \frac{\theta_m - \theta_n}{x_\ell}$$

Section 3 — OPF Formulation

The three constraint functions are:

  • power_balance: returns $\sum P_i - P_D = 0$ (equality)
  • line_flow_constraints: for every line, appends both $P_\ell^{\max} - P_\ell \geq 0$ and $P_\ell^{\max} + P_\ell \geq 0$ (inequality, in scipy sign convention requiring non-negative values)

total_cost_grad provides the analytic Jacobian $\partial C / \partial P_i = 2a_i P_i + b_i$, which significantly accelerates SLSQP convergence.

Section 4 — Solver

scipy.optimize.minimize with method="SLSQP" (Sequential Least Squares Quadratic Programming) handles the nonlinearly-constrained QP exactly. The initial point is set proportionally between $P^{\min}$ and $P^{\max}$. Tolerance ftol=1e-9 ensures high-precision convergence.

Section 5 — Visualization

Eight panels are arranged in a $3 \times 3$ GridSpec:

Panel 1 (top-left): Generator dispatch bar chart with dashed $P^{\max}$ lines and dotted $P^{\min}$ lines overlaid. Annotated with exact output values in MW.

Panel 2 (top-center): Cost share pie chart showing each generator’s contribution to total hourly operating cost in $/h.

Panel 3 (top-right): Horizontal bar chart of line utilization as a percentage of thermal limit. Color coding — blue (< 60%), green (60–90%), red (> 90%) — instantly identifies congested branches.

Panel 4 (middle-left/center span): Network topology diagram. Bus size encodes generator vs. load role; line thickness encodes utilization level; flow annotations show signed MW values on every branch.

Panel 5 (middle-right): Generator cost curves $C_i(P_i)$ over their full operating range, with optimal operating points marked as gold-rimmed scatter points.

Panel 6 (bottom-left): Marginal cost (incremental heat rate) $\lambda_i = 2a_i P_i + b_i$ for each generator at the optimum, with the weighted average $\bar{\lambda}$ shown as a dashed gold reference. Equal marginal cost across unconstrained generators is the necessary condition for optimality (the equal incremental cost criterion).

Panel 7 (bottom-center): Demand sweep from 40 MW to 420 MW. For each demand level, the full OPF is re-solved, showing how total optimal cost grows as a function of system load — including the kink where line thermal limits begin to bind.

Panel 8 (bottom-right, 3D): Cost surface over the feasible region in $(P_1, P_2)$ space. The third generator absorbs residual demand $P_3 = P_D - P_1 - P_2$ (clamped to capacity). The gold sphere marks the globally optimal dispatch point. The plasma colormap visually identifies the cost gradient direction, with the minimum sitting in the valley where the cheaper generators are loaded more heavily.


Execution Results

=======================================================
  OPF Solution  (SLSQP)
=======================================================
  Converged     : True  (Optimization terminated successfully)
  Total demand  : 220.0 MW
  Total gen     : 220.0000 MW

  Generator Dispatch:
    G1 (Bus 1): 101.579 MW  [10–200]  Load: 48.2%   Cost: 324.43 $/h
    G2 (Bus 2):  84.386 MW  [10–150]  Load: 53.1%   Cost: 254.62 $/h
    G3 (Bus 5):  34.035 MW  [10–100]  Load: 26.7%   Cost: 125.30 $/h

  Total cost    : 704.3544 $/h

  Line Flows:
    L12  (1→2):  +37.153 MW  / 150 MW  (24.8%)
    L14  (1→4):  +64.426 MW  / 100 MW  (64.4%)
    L23  (2→3):  +88.491 MW  / 100 MW  (88.5%)
    L24  (2→4):  +33.048 MW  / 80 MW  (41.3%)
    L35  (3→5):   -1.509 MW  / 60 MW  (2.5%)
    L45  (4→5):  +17.474 MW  / 60 MW  (29.1%)

  Voltage Angles (degrees):
    Bus 1:  +0.0000°
    Bus 2: -212.8704°
    Bus 3: -821.2894°
    Bus 4: -553.7012°
    Bus 5: -803.9971°
=======================================================

Figure saved as opf_results.png

Key Insights

Equal incremental cost criterion. For generators that are not at their capacity limits, the optimality condition is:

$$\frac{dC_i}{dP_i} = \lambda, \quad \forall i \text{ not at bounds}$$

This is the system lambda (locational marginal price in the lossless case). Generators at their $P^{\max}$ limit will have $\lambda_i < \lambda$; generators at $P^{\min}$ will have $\lambda_i > \lambda$.

DC OPF vs. AC OPF. This formulation ignores reactive power and voltage magnitude — the DC approximation. It is widely used in market clearing because it converts an otherwise non-convex AC problem into a convex QP (for quadratic costs), guaranteeing global optimality via SLSQP. Full AC OPF requires voltage magnitude as additional state variables and nonlinear power flow equations, making it significantly harder to solve at scale.

Congestion and locational pricing. When line flow limits bind (utilization → 100%), the shadow price of the flow constraint creates a spread in nodal prices across the network — the basis for locational marginal pricing (LMP) in electricity markets. The line utilization panel and topology diagram reveal which corridors are bottlenecks under optimal dispatch.