Thermal Generation Dispatch Sweep Example

A classic application is to allocate power generation to meet a demand $d$ at minimum cost, subject to capacity limits. We consider a simplified economic dispatch problem:

\[\begin{align*} \min_{g_i \ge 0,\;\phi \ge 0} & \quad \sum_{i=1}^n c_i\,g_i + \sum_{i=1}^n c_{2,i}\,g_i^2 + c_\phi \,\phi \\ \text{s.t.} & \quad \sum_{i=1}^n g_i + \phi \;=\; d, \quad (:\lambda)\\ & \quad 0 \;\le\; g_i \;\le\; G_i, \quad i=1,\dots,n, \end{align*}\]

where $g_i$ is the power generated by plant $i$, each with linear unit cost $c_i$, quadratic cost component $c_{2,i}$ and capacity $G_i$, and $\phi$ is unmet demand with penalty $c_\phi$. We treat $d$ (the system demand) as a parameter. Differentiating through this QP quantifies how optimal generation shifts as demand changes.

When $d$ changes by a small amount, DiffOpt.forward\_differentiate! solves a linear system capturing the KKT conditions, revealing how $g_1^*$, $g_2^*$, and $\phi^*$ shift with respect to $d$. These sensitivities are critical for power systems operators to understand how different plants ramp up or down as demand fluctuates.

Define and solve the Thermal Dispatch Problem for a range of demands

First, import the libraries.

using Test
using JuMP
import DiffOpt
import HiGHS
using Plots
using Plots.Measures

Fixed data

c = [20.0, 30.0]        # linear cost ($/MWh)
c2 = [0.2, 0.1]        # quadratic cost ($/MWh²)
G = [150.0, 80.0]      # capacities (MW)
cφ = 10_000.0            # penalty for unmet demand ($/MWh)

d_range = 11.0:4.0:300.0 # demand sweep (MW) – slightly coarser for clarity
N = length(d_range)
73

Storage for results

g1, g2, _φ = zeros(N), zeros(N), zeros(N)
dg1_dd, dg2_dd = zeros(N), zeros(N)
dφ_dd, dJ_dd = zeros(N), zeros(N)
([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0  …  0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])

Sweep & Differentiation

for (k, d_val) in enumerate(d_range)
    println("Demand: ", k, " of ", N, " (", d_val, ")")
    model = DiffOpt.quadratic_diff_model(HiGHS.Optimizer)
    set_silent(model)

    @variable(model, d in Parameter(d_val))          # parameter
    @variables(model, begin                          # decisions
        0 <= g[i=1:2] <= G[i]
        φ >= 0
    end)

    @objective(
        model,
        Min,                           # cost
        sum(c[i] * g[i] + c2[i] * g[i]^2 for i in 1:2) + cφ * φ
    )

    @constraint(model, con, sum(g) + φ == d)              # balance

    optimize!(model)

    # ---------- store primal results ----------
    g1[k] = value(g[1])
    g2[k] = value(g[2])
    _φ[k] = value(φ)

    # ---------- forward sensitivities ----------
    DiffOpt.empty_input_sensitivities!(model)
    DiffOpt.set_forward_parameter(model, d, 1.0)
    DiffOpt.forward_differentiate!(model)

    dg1_dd[k] = DiffOpt.get_forward_variable(model, g[1])
    dg2_dd[k] = DiffOpt.get_forward_variable(model, g[2])
    dφ_dd[k] = DiffOpt.get_forward_variable(model, φ)

    # marginal cost  λ
    dJ_dd[k] = dual.(con)
end
Demand: 1 of 73 (11.0)
Demand: 2 of 73 (15.0)
Demand: 3 of 73 (19.0)
Demand: 4 of 73 (23.0)
Demand: 5 of 73 (27.0)
Demand: 6 of 73 (31.0)
Demand: 7 of 73 (35.0)
Demand: 8 of 73 (39.0)
Demand: 9 of 73 (43.0)
Demand: 10 of 73 (47.0)
Demand: 11 of 73 (51.0)
Demand: 12 of 73 (55.0)
Demand: 13 of 73 (59.0)
Demand: 14 of 73 (63.0)
Demand: 15 of 73 (67.0)
Demand: 16 of 73 (71.0)
Demand: 17 of 73 (75.0)
Demand: 18 of 73 (79.0)
Demand: 19 of 73 (83.0)
Demand: 20 of 73 (87.0)
Demand: 21 of 73 (91.0)
Demand: 22 of 73 (95.0)
Demand: 23 of 73 (99.0)
Demand: 24 of 73 (103.0)
Demand: 25 of 73 (107.0)
Demand: 26 of 73 (111.0)
Demand: 27 of 73 (115.0)
Demand: 28 of 73 (119.0)
Demand: 29 of 73 (123.0)
Demand: 30 of 73 (127.0)
Demand: 31 of 73 (131.0)
Demand: 32 of 73 (135.0)
Demand: 33 of 73 (139.0)
Demand: 34 of 73 (143.0)
Demand: 35 of 73 (147.0)
Demand: 36 of 73 (151.0)
Demand: 37 of 73 (155.0)
Demand: 38 of 73 (159.0)
Demand: 39 of 73 (163.0)
Demand: 40 of 73 (167.0)
Demand: 41 of 73 (171.0)
Demand: 42 of 73 (175.0)
Demand: 43 of 73 (179.0)
Demand: 44 of 73 (183.0)
Demand: 45 of 73 (187.0)
Demand: 46 of 73 (191.0)
Demand: 47 of 73 (195.0)
Demand: 48 of 73 (199.0)
Demand: 49 of 73 (203.0)
Demand: 50 of 73 (207.0)
Demand: 51 of 73 (211.0)
Demand: 52 of 73 (215.0)
Demand: 53 of 73 (219.0)
Demand: 54 of 73 (223.0)
Demand: 55 of 73 (227.0)
Demand: 56 of 73 (231.0)
Demand: 57 of 73 (235.0)
Demand: 58 of 73 (239.0)
Demand: 59 of 73 (243.0)
Demand: 60 of 73 (247.0)
Demand: 61 of 73 (251.0)
Demand: 62 of 73 (255.0)
Demand: 63 of 73 (259.0)
Demand: 64 of 73 (263.0)
Demand: 65 of 73 (267.0)
Demand: 66 of 73 (271.0)
Demand: 67 of 73 (275.0)
Demand: 68 of 73 (279.0)
Demand: 69 of 73 (283.0)
Demand: 70 of 73 (287.0)
Demand: 71 of 73 (291.0)
Demand: 72 of 73 (295.0)
Demand: 73 of 73 (299.0)

Results with Plot graphs

default(;
    size = (1150, 350),
    legendfontsize = 8,
    guidefontsize = 9,
    tickfontsize = 7,
)

Stacked-area dispatch plot

plt_dispatch = plot(
    d_range,
    g1;                    # lower layer
    fillrange = 0,                                  # fill down to zero
    label = "g₁",
    xlabel = "Demand (MWh)",
    ylabel = "g (MWh)",
    left_margin = 5Measures.mm,
    bottom_margin = 5Measures.mm,
    title = "Optimal dispatch",
);

plot!(
    plt_dispatch,
    d_range,
    g1 .+ g2;              # upper layer
    fillrange = g1,                                 # fill down to g₁
    label = "g₂",
);

plot!(
    plt_dispatch,
    d_range,
    _φ;                     # unmet demand as a line
    lw = 2,
    c = :red,
    label = "φ (unserved)",
);

capacity_limit = d_range[findfirst(g2 .== 80.0)]

vline!(
    plt_dispatch,
    [capacity_limit, sum(G)];
    l = :dash,
    c = :black,
    label = "capacity limit",
);

(b) marginal cost λ(d)

plt_mc = plot(
    d_range,
    dJ_dd;
    lw = 2,
    c = :black,
    xlabel = "Demand (MWh)",
    ylabel = "λ (\$/MWh)",
    title = "Electricity Price",
    yscale = :log10,
    left_margin = 5Measures.mm,
    bottom_margin = 5Measures.mm,
    legend = false,
);

vline!(plt_mc, [capacity_limit, sum(G)]; l = :dash, c = :gray, label = "");

(c) generation sensitivities

plt_sens = plot(
    d_range,
    dg1_dd;
    lw = 2,
    xlabel = "Demand (MWh)",
    ylabel = "∂g/∂d  (MWh per MWh)",
    title = "Generation Sensitivities",
    label = "∂g₁/∂d",
);

plot!(plt_sens, d_range, dg2_dd; lw = 2, label = "∂g₂/∂d");
hline!(plt_sens, [0, 1]; l = :dot, c = :gray, label = "");

vline!(plt_sens, [capacity_limit, sum(G)]; l = :dash, c = :gray, label = "");

combine all

plot_all = plot(plt_dispatch, plt_mc, plt_sens; layout = (1, 3))
Example block output

Optimal dispatch (left), marginal electricity price $\lambda(d)=\partial J/\partial d$ (center, logarithmic scale), and forward sensitivities $\partial g_i/\partial d$ (right) for demands ranging from $\approx 0$ to MWh. The vertical dashed lines mark when the individual plant capacities are reached–-$g_2$ reaches it's capacity at MWh and $g_1$ at MWh.


This page was generated using Literate.jl.