Документация Engee

Энергетические системы

Это руководство было изначально предоставлено Юрием Дворкиным (Yury Dvorkin) и Майлзом Любиным (Miles Lubin).

В этом руководстве демонстрируется формулирование базовых моделей проектирования энергосистем в JuMP.

Мы будем рассматривать базовые модели « экономичного распределения нагрузки» и «составления графика нагрузки энергоблоков», не принимая во внимание ограничения передачи.

В этом руководстве используются следующие пакеты:

using JuMP
import DataFrames
import HiGHS
import Plots
import StatsPlots

Экономичное распределение нагрузки

Экономичное распределение нагрузки (ED) — это задача оптимизации, цель которой — минимизировать затраты на удовлетворение спроса на электроэнергию с учетом эксплуатационных ограничений активов энергосистемы. В простейшей модификации ED представляет собой задачу линейного программирования, решаемую для прогноза совокупной нагрузки и силы ветра и для одного бесконечно малого момента времени.

Математически задачу ED можно записать следующим образом:

где и  — это инкрементные расходы (долл./МВт·ч) и выходная мощность (МВт) generator, respectively, and and are the incremental cost ($/МВт·ч) и подача ветровой энергии (МВт) соответственно.

Действуют следующие ограничения:

  • Минимальный ( ) и максимальный ( ) пределы выходной мощности генераторов:

  • Ограничение на подачу ветровой мощности: где и  — это соответственно подача ветровой мощности и прогноз ветровой мощности.

  • Ограничение баланса мощности: где  — это прогнозируемое потребление.

Дополнительные сведения о моделях ED можно найти в работе A. J. Wood, B. F. Wollenberg, and G. B. Sheblé, Power Generation, Operation and Control, Wiley, 2013.

Определим некоторые входные данные тестовой системы.

Определим несколько тепловых генераторов:

function ThermalGenerator(
    min::Float64,
    max::Float64,
    fixed_cost::Float64,
    variable_cost::Float64,
)
    return (
        min = min,
        max = max,
        fixed_cost = fixed_cost,
        variable_cost = variable_cost,
    )
end

generators = [
    ThermalGenerator(0.0, 1000.0, 1000.0, 50.0),
    ThermalGenerator(300.0, 1000.0, 0.0, 100.0),
]
2-element Vector{@NamedTuple{min::Float64, max::Float64, fixed_cost::Float64, variable_cost::Float64}}:
 (min = 0.0, max = 1000.0, fixed_cost = 1000.0, variable_cost = 50.0)
 (min = 300.0, max = 1000.0, fixed_cost = 0.0, variable_cost = 100.0)

Ветрогенератор:

WindGenerator(variable_cost::Float64) = (variable_cost = variable_cost,)

wind_generator = WindGenerator(50.0)
(variable_cost = 50.0,)

И сценарий:

function Scenario(demand::Float64, wind::Float64)
    return (demand = demand, wind = wind)
end

scenario = Scenario(1500.0, 200.0)
(demand = 1500.0, wind = 200.0)

Создадим функцию solve_economic_dispatch, которая решает задачу экономичного распределения нагрузки для заданного набора входных параметров.

function solve_economic_dispatch(generators::Vector, wind, scenario)
    # Определяем модель экономичного распределения нагрузки (ED)
    model = Model(HiGHS.Optimizer)
    set_silent(model)
    # Определяем переменные решения
    # выходная мощность генераторов
    N = length(generators)
    @variable(model, generators[i].min <= g[i = 1:N] <= generators[i].max)
    # подача ветровой мощности
    @variable(model, 0 <= w <= scenario.wind)
    # Определяем целевую функцию
    @objective(
        model,
        Min,
        sum(generators[i].variable_cost * g[i] for i in 1:N) +
        wind.variable_cost * w,
    )
    # Определяем ограничение баланса мощности
    @constraint(model, sum(g[i] for i in 1:N) + w == scenario.demand)
    # Оператор решения
    optimize!(model)
    @assert is_solved_and_feasible(model)
    # Возвращаем оптимальное значение целевой функции и ее минимизаторы
    return (
        g = value.(g),
        w = value(w),
        wind_spill = scenario.wind - value(w),
        total_cost = objective_value(model),
    )
end
solve_economic_dispatch (generic function with 1 method)

Решим задачу экономичного распределения нагрузки:

solution = solve_economic_dispatch(generators, wind_generator, scenario);

println("Dispatch of Generators: ", solution.g, " MW")
println("Dispatch of Wind: ", solution.w, " MW")
println("Wind spillage: ", solution.wind_spill, " MW")
println("Total cost: \$", solution.total_cost)
Dispatch of Generators: [1000.0, 300.0] MW
Dispatch of Wind: 200.0 MW
Wind spillage: 0.0 MW
Total cost: $90000.0

Экономичное распределение нагрузки с регулируемыми инкрементными расходами

В следующем упражнении мы скорректируем инкрементные расходы генератора G1 и пронаблюдаем, как это повлияет на общие расходы.

function scale_generator_cost(g, scale)
    return ThermalGenerator(g.min, g.max, g.fixed_cost, scale * g.variable_cost)
end

start = time()
c_g_scale_df = DataFrames.DataFrame(;
    # Коэффициент масштабирования
    scale = Float64[],
    # Распределение генератора 1 [МВт]
    dispatch_G1 = Float64[],
    # Распределение генератора 2 [МВт]
    dispatch_G2 = Float64[],
    # Распределение ветрогенератора [МВт]
    dispatch_wind = Float64[],
    # Потери ветровой мощности [МВт]
    spillage_wind = Float64[],
    # Общие расходы [$]
    total_cost = Float64[],
)
for c_g1_scale in 0.5:0.1:3.0
    # Инкрементные расходы первого генератора обновляются в каждой итерации.
    new_generators = scale_generator_cost.(generators, [c_g1_scale, 1.0])
    # Решаем задачу экономичного распределения нагрузки с обновленными инкрементными расходами
    sol = solve_economic_dispatch(new_generators, wind_generator, scenario)
    push!(
        c_g_scale_df,
        (c_g1_scale, sol.g[1], sol.g[2], sol.w, sol.wind_spill, sol.total_cost),
    )
end
print(string("elapsed time: ", time() - start, " seconds"))
elapsed time: 0.30464911460876465 seconds
c_g_scale_df

26×6 DataFrame

Rowscaledispatch_G1dispatch_G2dispatch_windspillage_windtotal_cost
Float64Float64Float64Float64Float64Float64
10.51000.0300.0200.00.065000.0
20.61000.0300.0200.00.070000.0
30.71000.0300.0200.00.075000.0
40.81000.0300.0200.00.080000.0
50.91000.0300.0200.00.085000.0
61.01000.0300.0200.00.090000.0
71.11000.0300.0200.00.095000.0
81.21000.0300.0200.00.0100000.0
91.31000.0300.0200.00.0105000.0
101.41000.0300.0200.00.0110000.0
111.51000.0300.0200.00.0115000.0
121.61000.0300.0200.00.0120000.0
131.71000.0300.0200.00.0125000.0
141.81000.0300.0200.00.0130000.0
151.91000.0300.0200.00.0135000.0
162.0300.01000.0200.00.0140000.0
172.1300.01000.0200.00.0141500.0
182.2300.01000.0200.00.0143000.0
192.3300.01000.0200.00.0144500.0
202.4300.01000.0200.00.0146000.0
212.5300.01000.0200.00.0147500.0
222.6300.01000.0200.00.0149000.0
232.7300.01000.0200.00.0150500.0
242.8300.01000.0200.00.0152000.0
252.9300.01000.0200.00.0153500.0
263.0300.01000.0200.00.0155000.0

Изменяем модель JuMP на месте

Обратите внимание, что в предыдущем упражнении модель оптимизации полностью перестраивалась в каждой итерации внутреннего цикла, что создавало дополнительную вычислительную нагрузку. Эту нагрузку можно снизить, если вместо перестройки всей модели будут изменяться только ограничения или целевая функция, как показано в примере ниже.

Сравните время вычисления для приведенных выше и ниже моделей.

function solve_economic_dispatch_inplace(
    generators::Vector,
    wind,
    scenario,
    scale::AbstractVector{Float64},
)
    obj_out = Float64[]
    w_out = Float64[]
    g1_out = Float64[]
    g2_out = Float64[]
    # Эта функция работает только для двух генераторов
    @assert length(generators) == 2
    model = Model(HiGHS.Optimizer)
    set_silent(model)
    N = length(generators)
    @variable(model, generators[i].min <= g[i = 1:N] <= generators[i].max)
    @variable(model, 0 <= w <= scenario.wind)
    @objective(
        model,
        Min,
        sum(generators[i].variable_cost * g[i] for i in 1:N) +
        wind.variable_cost * w,
    )
    @constraint(model, sum(g[i] for i in 1:N) + w == scenario.demand)
    for c_g1_scale in scale
        @objective(
            model,
            Min,
            c_g1_scale * generators[1].variable_cost * g[1] +
            generators[2].variable_cost * g[2] +
            wind.variable_cost * w,
        )
        optimize!(model)
        @assert is_solved_and_feasible(model)
        push!(obj_out, objective_value(model))
        push!(w_out, value(w))
        push!(g1_out, value(g[1]))
        push!(g2_out, value(g[2]))
    end
    df = DataFrames.DataFrame(;
        scale = scale,
        dispatch_G1 = g1_out,
        dispatch_G2 = g2_out,
        dispatch_wind = w_out,
        spillage_wind = scenario.wind .- w_out,
        total_cost = obj_out,
    )
    return df
end

start = time()
inplace_df = solve_economic_dispatch_inplace(
    generators,
    wind_generator,
    scenario,
    0.5:0.1:3.0,
)
print(string("elapsed time: ", time() - start, " seconds"))
elapsed time: 0.22562503814697266 seconds

Для небольших моделей корректировка определенных ограничений или целевой функции иногда повышает, а иногда снижает скорость выполнения по сравнению с перестроением всей модели. Однако по мере увеличения размера задачи обновление модели на месте обычно происходит быстрее.

inplace_df

26×6 DataFrame

Rowscaledispatch_G1dispatch_G2dispatch_windspillage_windtotal_cost
Float64Float64Float64Float64Float64Float64
10.51000.0300.0200.00.065000.0
20.61000.0300.0200.00.070000.0
30.71000.0300.0200.00.075000.0
40.81000.0300.0200.00.080000.0
50.91000.0300.0200.00.085000.0
61.01000.0300.0200.00.090000.0
71.11000.0300.0200.00.095000.0
81.21000.0300.0200.00.0100000.0
91.31000.0300.0200.00.0105000.0
101.41000.0300.0200.00.0110000.0
111.51000.0300.0200.00.0115000.0
121.61000.0300.0200.00.0120000.0
131.71000.0300.0200.00.0125000.0
141.81000.0300.0200.00.0130000.0
151.91000.0300.0200.00.0135000.0
162.01000.0300.0200.00.0140000.0
172.1300.01000.0200.00.0141500.0
182.2300.01000.0200.00.0143000.0
192.3300.01000.0200.00.0144500.0
202.4300.01000.0200.00.0146000.0
212.5300.01000.0200.00.0147500.0
222.6300.01000.0200.00.0149000.0
232.7300.01000.0200.00.0150500.0
242.8300.01000.0200.00.0152000.0
252.9300.01000.0200.00.0153500.0
263.0300.01000.0200.00.0155000.0

Неэффективное использование ветрогенераторов

Задача экономичного распределения нагрузки не предполагает принятия решений о пуске и останове энергоблоков, поэтому предполагается, что все генераторы должны диспетчеризоваться по крайней мере на минимальном уровне выходной мощности. Такой подход неэффективен с точки зрения затрат и может приводить к абсурдным решениям. Например, если , то подача ветровой мощности должна быть равна нулю, то есть вся доступная ветровая генерация теряется для выполнения ограничений на минимальную выходную мощность генераторов.

В следующем примере мы корректируем общее потребление и наблюдаем, как это влияет на потери ветровой мощности.

demand_scale_df = DataFrames.DataFrame(;
    demand = Float64[],
    dispatch_G1 = Float64[],
    dispatch_G2 = Float64[],
    dispatch_wind = Float64[],
    spillage_wind = Float64[],
    total_cost = Float64[],
)

function scale_demand(scenario, scale)
    return Scenario(scale * scenario.demand, scenario.wind)
end

for demand_scale in 0.2:0.1:1.4
    new_scenario = scale_demand(scenario, demand_scale)
    sol = solve_economic_dispatch(generators, wind_generator, new_scenario)
    push!(
        demand_scale_df,
        (
            new_scenario.demand,
            sol.g[1],
            sol.g[2],
            sol.w,
            sol.wind_spill,
            sol.total_cost,
        ),
    )
end

demand_scale_df

13×6 DataFrame

Rowdemanddispatch_G1dispatch_G2dispatch_windspillage_windtotal_cost
Float64Float64Float64Float64Float64Float64
1300.00.0300.00.0200.030000.0
2450.0150.0300.00.0200.037500.0
3600.0300.0300.00.0200.045000.0
4750.0450.0300.00.0200.052500.0
5900.0600.0300.00.0200.060000.0
61050.0750.0300.00.0200.067500.0
71200.0900.0300.00.0200.075000.0
81350.0850.0300.0200.00.082500.0
91500.01000.0300.0200.00.090000.0
101650.01000.0450.0200.00.0105000.0
111800.01000.0600.0200.00.0120000.0
121950.01000.0750.0200.00.0135000.0
132100.01000.0900.0200.00.0150000.0

dispatch_plot = StatsPlots.@df(
    demand_scale_df,
    Plots.plot(
        :demand,
        [:dispatch_G1, :dispatch_G2],
        labels = ["G1" "G2"],
        title = "Thermal Dispatch",
        legend = :bottomright,
        linewidth = 3,
        xlabel = "Demand",
        ylabel = "Dispatch [MW]",
    ),
)

wind_plot = StatsPlots.@df(
    demand_scale_df,
    Plots.plot(
        :demand,
        [:dispatch_wind, :spillage_wind],
        labels = ["Dispatch" "Spillage"],
        title = "Wind",
        legend = :bottomright,
        linewidth = 3,
        xlabel = "Demand [MW]",
        ylabel = "Energy [MW]",
    ),
)

Plots.plot(dispatch_plot, wind_plot)

Этот недостаток можно преодолеть, введя двоичные переменные решения по состоянию включения генераторов. Такая модель называется моделью составления графика нагрузки энергоблоков и рассматривается далее в этом руководстве.

За дополнительной информацией о взаимосвязи между ветровой генерацией и ограничениями на минимальную выходную мощность генераторов мы отсылаем заинтересованных читателей к статье R. Baldick, Wind and energy markets: a case study of Texas, IEEE Systems Journal, vol. 6, pp. 27—​34, 2012.

Составление графика нагрузки энергоблоков

Модель составления графика нагрузки энергоблоков (UC) можно получить из модели ED путем введения двоичной переменной, связанной с каждым генератором. Эта двоичная переменная может принимать два значения: если она равна 1, генератор синхронизирован и поэтому может быть запущен; в противном случае, то есть если двоичная переменная равна 0, генератор не синхронизирован и его выходная мощность установлена на 0.

Чтобы получить математическую формулировку модели UC, мы изменим ограничения модели ED следующим образом:

где $u_{i} \in {0,1}.$ В этом ограничении, если $u_{i} = 0$, то $g_{i} = 0$. С другой стороны, если $u_{i} = 1$, то $g^{min}_{i} \leq g_{i} \leq g^{max}_{i}$.

За дополнительной информацией о задаче UC мы отсылаем заинтересованных читателей к статье G. Morales-Espana, J. M. Latorre, and A. Ramos, Tight and Compact MILP Formulation for the Thermal Unit Commitment Problem, IEEE Transactions on Power Systems, vol. 28, pp. 4897—​4908, 2013.

В следующем примере описанная выше модель ED преобразовывается в модель UC.

function solve_unit_commitment(generators::Vector, wind, scenario)
    model = Model(HiGHS.Optimizer)
    set_silent(model)
    N = length(generators)
    @variable(model, 0 <= g[i = 1:N] <= generators[i].max)
    @variable(model, 0 <= w <= scenario.wind)
    @constraint(model, sum(g[i] for i in 1:N) + w == scenario.demand)
    # !!! Нововведение: добавляем двоичные переменные включения-выключения для каждого генератора
    @variable(model, u[i = 1:N], Bin)
    @constraint(model, [i = 1:N], g[i] <= generators[i].max * u[i])
    @constraint(model, [i = 1:N], g[i] >= generators[i].min * u[i])
    @objective(
        model,
        Min,
        sum(generators[i].variable_cost * g[i] for i in 1:N) +
        wind.variable_cost * w +
        # !!! новое
        sum(generators[i].fixed_cost * u[i] for i in 1:N)
    )
    optimize!(model)
    status = termination_status(model)
    if status != OPTIMAL
        return (status = status,)
    end
    @assert primal_status(model) == FEASIBLE_POINT
    return (
        status = status,
        g = value.(g),
        w = value(w),
        wind_spill = scenario.wind - value(w),
        u = value.(u),
        total_cost = objective_value(model),
    )
end
solve_unit_commitment (generic function with 1 method)

Решаем задачу составления графика нагрузки энергоблоков

solution = solve_unit_commitment(generators, wind_generator, scenario)

println("Dispatch of Generators: ", solution.g, " MW")
println("Commitments of Generators: ", solution.u)
println("Dispatch of Wind: ", solution.w, " MW")
println("Wind spillage: ", solution.wind_spill, " MW")
println("Total cost: \$", solution.total_cost)
Dispatch of Generators: [1000.0, 300.0] MW
Commitments of Generators: [1.0, 1.0]
Dispatch of Wind: 200.0 MW
Wind spillage: 0.0 MW
Total cost: $91000.0

Составление графика нагрузки энергоблоков как функция от потребления

Реализовав модель составления графика нагрузки энергоблоков, теперь мы можем оценить взаимосвязь между минимальными ограничениями на минимальную выходную мощность генераторов и ветрогенерацией.

uc_df = DataFrames.DataFrame(;
    demand = Float64[],
    commitment_G1 = Float64[],
    commitment_G2 = Float64[],
    dispatch_G1 = Float64[],
    dispatch_G2 = Float64[],
    dispatch_wind = Float64[],
    spillage_wind = Float64[],
    total_cost = Float64[],
)

for demand_scale in 0.2:0.1:1.4
    new_scenario = scale_demand(scenario, demand_scale)
    sol = solve_unit_commitment(generators, wind_generator, new_scenario)
    if sol.status == OPTIMAL
        push!(
            uc_df,
            (
                new_scenario.demand,
                sol.u[1],
                sol.u[2],
                sol.g[1],
                sol.g[2],
                sol.w,
                sol.wind_spill,
                sol.total_cost,
            ),
        )
    end
    println("Status: stem:[(sol.status) for demand_scale = ](demand_scale)")
end
Status: OPTIMAL for demand_scale = 0.2
Status: OPTIMAL for demand_scale = 0.3
Status: OPTIMAL for demand_scale = 0.4
Status: OPTIMAL for demand_scale = 0.5
Status: OPTIMAL for demand_scale = 0.6
Status: OPTIMAL for demand_scale = 0.7
Status: OPTIMAL for demand_scale = 0.8
Status: OPTIMAL for demand_scale = 0.9
Status: OPTIMAL for demand_scale = 1.0
Status: OPTIMAL for demand_scale = 1.1
Status: OPTIMAL for demand_scale = 1.2
Status: OPTIMAL for demand_scale = 1.3
Status: OPTIMAL for demand_scale = 1.4
uc_df

13×8 DataFrame

Rowdemandcommitment_G1commitment_G2dispatch_G1dispatch_G2dispatch_windspillage_windtotal_cost
Float64Float64Float64Float64Float64Float64Float64Float64
1300.01.00.0100.00.0200.00.016000.0
2450.01.00.0250.00.0200.00.023500.0
3600.01.00.0400.00.0200.00.031000.0
4750.01.00.0550.00.0200.00.038500.0
5900.01.00.0700.00.0200.00.046000.0
61050.01.00.0850.00.0200.00.053500.0
71200.01.00.01000.00.0200.00.061000.0
81350.01.01.0850.0300.0200.00.083500.0
91500.01.01.01000.0300.0200.00.091000.0
101650.01.01.01000.0450.0200.00.0106000.0
111800.01.01.01000.0600.0200.00.0121000.0
121950.01.01.01000.0750.0200.00.0136000.0
132100.01.01.01000.0900.0200.00.0151000.0

commitment_plot = StatsPlots.@df(
    uc_df,
    Plots.plot(
        :demand,
        [:commitment_G1, :commitment_G2],
        labels = ["G1" "G2"],
        title = "Commitment",
        legend = :bottomright,
        linewidth = 3,
        xlabel = "Demand [MW]",
        ylabel = "Commitment decision {0, 1}",
    ),
)

dispatch_plot = StatsPlots.@df(
    uc_df,
    Plots.plot(
        :demand,
        [:dispatch_G1, :dispatch_G2, :dispatch_wind],
        labels = ["G1" "G2" "Wind"],
        title = "Dispatch [MW]",
        legend = :bottomright,
        linewidth = 3,
        xlabel = "Demand",
        ylabel = "Dispatch [MW]",
    ),
)

Plots.plot(commitment_plot, dispatch_plot)

Нелинейное экономичное распределение нагрузки

В качестве последнего примера мы изменим задачу экономичного распределения нагрузки двумя способами:

  • Функция тепловых затрат определяется пользователем

  • Выход ветровой энергии равен всего лишь квадратному корню из распределения

import Ipopt

"""
    thermal_cost_function(g)


A user-defined thermal cost function in pure-Julia! You can include
nonlinearities, and even things like control flow.

[WARNING]
====
It's still up to you to make sure that the function has a meaningful
====
    derivative.
"""

function thermal_cost_function(g)
    if g <= 500
        return g
    else
        return g + 1e-2 * (g - 500)^2
    end
end

function solve_nonlinear_economic_dispatch(
    generators::Vector,
    wind,
    scenario;
    silent::Bool = false,
)
    model = Model(Ipopt.Optimizer)
    if silent
        set_silent(model)
    end
    @operator(model, op_tcf, 1, thermal_cost_function)
    N = length(generators)
    @variable(model, generators[i].min <= g[i = 1:N] <= generators[i].max)
    @variable(model, 0 <= w <= scenario.wind)
    @objective(
        model,
        Min,
        sum(generators[i].variable_cost * op_tcf(g[i]) for i in 1:N) +
        wind.variable_cost * w,
    )
    @constraint(model, sum(g[i] for i in 1:N) + sqrt(w) == scenario.demand)
    optimize!(model)
    @assert is_solved_and_feasible(model)
    return (
        g = value.(g),
        w = value(w),
        wind_spill = scenario.wind - value(w),
        total_cost = objective_value(model),
    )
end

solution =
    solve_nonlinear_economic_dispatch(generators, wind_generator, scenario)
(g = [847.3509933774712, 648.6754966887423], w = 15.78878119389903, wind_spill = 184.21121880610096, total_cost = 190455.298013245)

Теперь давайте посмотрим, как распределяется ветровая энергия в зависимости от расходов:

wind_cost = 0.0:1:100
wind_dispatch = Float64[]
for c in wind_cost
    sol = solve_nonlinear_economic_dispatch(
        generators,
        WindGenerator(c),
        scenario;
        silent = true,
    )
    push!(wind_dispatch, sol.w)
end

Plots.plot(
    wind_cost,
    wind_dispatch;
    xlabel = "Cost",
    ylabel = "Dispatch [MW]",
    label = false,
)