Unit Commitment

Unit commitment decides not only how much an asset produces, but which individual units are operating. It is useful when a generator has a minimum stable output, a no-load cost, or minimum up and down times.

In this tutorial you will:

  1. Compare none, basic, and 3var unit commitment on one hourly week.
  2. Inspect units_on, start_up, and shut_down.
  3. Coarsen the resolution of unit commitment variables and measure the resulting size and accuracy tradeoff.

For the formulation, see Unit Commitment Constraints. The configuration parameters are described in Unit Commitment constraints, and the resolution details are covered in Flexible Time Resolution in the Unit Commitment and Ramping Constraints.

The energy system

This case is based on the energy system from the Basics tutorial, with a single 168-hour week selected from the original hourly profiles.

AssetUnitsCapacity per unitMinimum operating pointUC method
ccgt4200 MW40%3var
ocgt250 MW20%3var
wind, solar, ens1Existing capacities0%none

The ccgt and ocgt capacities are unchanged in aggregate: four 200 MW units provide 800 MW and two 50 MW units provide 100 MW. Their units_on_cost values represent the cost of keeping one unit online for one hour.

import TulipaIO as TIO
import TulipaEnergyModel as TEM
using DuckDB
using DataFrames
using JuMP
using Plots

input_dir = joinpath(@__DIR__, "my-awesome-energy-system/tutorial-uc")

function load_uc_connection()
    connection = DBInterface.connect(DuckDB.DB)
    TIO.read_csv_folder(connection, input_dir)
    return connection
end

connection = load_uc_connection()
TIO.get_table(connection, "asset")
9×13 DataFrame
Rowassettypecapacityvintage_methodinvestment_integertechnical_lifetimediscount_rateminimum_down_timeminimum_up_timeinter_period_storage_level_boundsmin_operating_pointunit_commitmentunit_commitment_integer
StringStringFloat64StringBoolInt64Float64Int64Int64StringFloat64StringBool
1ccgtproducer200.0aggregatedtrue150.0546inter_period_only0.43vartrue
2e_demandconsumer0.0aggregatedfalse150.0511inter_period_only0.0nonefalse
3ensproducer1000.0aggregatedfalse150.0511inter_period_only0.0nonefalse
4ocgtproducer50.0aggregatedtrue150.0511inter_period_only0.23vartrue
5solarproducer500.0aggregatedtrue150.0511inter_period_only0.0nonefalse
6windproducer400.0aggregatedtrue150.0511inter_period_only0.0nonefalse
7electrolizerconversion100.0aggregatedtrue150.0511inter_period_only0.0nonefalse
8h2_demandconsumer100.0aggregatedfalse150.0511inter_period_only0.0nonefalse
9smr_ccsproducer100.0aggregatedtrue150.0511inter_period_only0.0nonefalse

The default data contains the 3-variable formulation. The two thermal assets are the only assets with unit commitment:

DataFrame(DuckDB.query(connection, """
    SELECT
        asset, capacity, min_operating_point, unit_commitment,
        unit_commitment_integer, minimum_up_time, minimum_down_time
    FROM asset
    WHERE asset IN ('ccgt', 'ocgt')
    ORDER BY asset
"""))
2×7 DataFrame
Rowassetcapacitymin_operating_pointunit_commitmentunit_commitment_integerminimum_up_timeminimum_down_time
StringFloat64Float64StringBoolInt64Int64
1ccgt200.00.43vartrue64
2ocgt50.00.23vartrue11

Part 1: hourly unit commitment

The helper below creates a fresh model for each comparison. Fresh connections are important because run_scenario creates internal tables and writes solution values into the connection.

function run_uc_case(
    unit_commitment;
    minimum_up_time = nothing,
    minimum_down_time = nothing,
    asset_partition = 1,
    flow_partition = nothing,
)
    connection = load_uc_connection()
    DuckDB.query(connection, """
        UPDATE asset
        SET unit_commitment = '$unit_commitment'
        WHERE asset IN ('ccgt', 'ocgt')
    """)
    if !isnothing(minimum_up_time)
        DuckDB.query(connection, """
            UPDATE asset
            SET minimum_up_time = $minimum_up_time,
                minimum_down_time = $minimum_down_time
            WHERE asset IN ('ccgt', 'ocgt')
        """)
    end

    if unit_commitment == "none"
        DuckDB.query(connection, """
            UPDATE asset
            SET min_operating_point = 0.0
            WHERE asset IN ('ccgt', 'ocgt')
        """)
    end

    if asset_partition > 1
        DuckDB.query(connection, """
            CREATE OR REPLACE TABLE assets_rep_periods_partitions AS
            SELECT * FROM (VALUES
                ('ccgt', 2030, 1, '$asset_partition', 'uniform'),
                ('ocgt', 2030, 1, '$asset_partition', 'uniform')
            ) AS partitions(asset, milestone_year, rep_period, partition, specification)
        """)
    end

    if !isnothing(flow_partition)
        DuckDB.query(connection, """
            CREATE OR REPLACE TABLE flows_rep_periods_partitions AS
            SELECT * FROM (VALUES
                ('ccgt', 'e_demand', 2030, 1, '$flow_partition', 'uniform'),
                ('ocgt', 'e_demand', 2030, 1, '$flow_partition', 'uniform')
            ) AS partitions(from_asset, to_asset, milestone_year, rep_period, partition, specification)
        """)
    end

    TEM.populate_with_defaults!(connection)
    energy_problem = nothing
    model_build_time = @elapsed begin
        energy_problem = TEM.EnergyProblem(connection; show_log = false)
        TEM.create_model!(energy_problem; show_log = false)
    end
    TEM.solve_model!(energy_problem)
    solver_time = JuMP.solve_time(energy_problem.model)
    TEM.save_solution!(energy_problem)
    timings = (; model_build_time, solver_time)
    return connection, energy_problem, timings
end
run_uc_case (generic function with 1 method)

No unit commitment

With unit_commitment = "none", the model dispatches the available capacity economically. It does not create units_on, start_up, or shut_down variables.

none_connection, none_problem, _ = run_uc_case("none")
(none_objective = none_problem.objective_value,
 variables = JuMP.num_variables(none_problem.model),
 constraints = JuMP.num_constraints(none_problem.model; count_variable_in_set_constraints = false))
(none_objective = 3.3458135033921525e6, variables = 1344, constraints = 1680)

none can still be combined with a positive min_operating_point to represent a must-run asset. That creates a minimum-output constraint, but it does not create commitment variables.

Basic unit commitment

The basic formulation adds units_on. Output is bounded below by the minimum operating point and above by the capacity of the units that are on.

basic_connection, basic_problem, _ = run_uc_case("basic")
units_on_basic = TIO.get_table(basic_connection, "var_units_on")
first(units_on_basic, 8)
8×8 DataFrame
Rowidassetmilestone_yearrep_periodtime_block_starttime_block_endunit_commitment_integersolution
Int64StringInt32Int32Int32Int32BoolFloat64
11ccgt2030111true4.0
22ccgt2030122true4.0
33ccgt2030133true4.0
44ccgt2030144true3.0
55ccgt2030155true4.0
66ccgt2030166true4.0
77ccgt2030177true4.0
88ccgt2030188true4.0

The ccgt output and its commitment state can be plotted together. The shaded interval is the feasible output band for the committed units.

basic_flow = TIO.get_table(basic_connection, "var_flow")
basic_ccgt_flow = filter(row ->
    row.from_asset == "ccgt" && row.to_asset == "e_demand", basic_flow)
basic_units_on = filter(row -> row.asset == "ccgt", units_on_basic)

plot(
    basic_ccgt_flow.time_block_start,
    basic_ccgt_flow.solution;
    label = "ccgt output",
    xlabel = "Hour",
    ylabel = "MW",
    xlims = (1, 72),
    legend = :topright,
)
plot!(
    basic_units_on.time_block_start,
    200 .* basic_units_on.solution;
    fillrange = 0.4 .* 200 .* basic_units_on.solution,
    fillalpha = 0.2,
    label = "ccgt capacity band",
)
Example block output

Three-variable unit commitment

The 3var formulation adds start_up and shut_down and links them to changes in units_on. It also enforces minimum up and down times.

First use one-hour minimum times. This isolates the difference between the variable sets: the objective should match basic for this case.

threevar_hourly_connection, threevar_hourly_problem, _ = run_uc_case(
    "3var";
    minimum_up_time = 1,
    minimum_down_time = 1,
)

threevar_hourly_problem.objective_value
4.048014636861077e6

Now use the tutorial defaults: ccgt has a 6-hour minimum up time and 4-hour minimum down time, while ocgt keeps 1-hour limits:

threevar_connection, threevar_problem, _ = run_uc_case(
    "3var";
)

start_up = TIO.get_table(threevar_connection, "var_start_up")
shut_down = TIO.get_table(threevar_connection, "var_shut_down")
first(start_up[start_up.asset .== "ccgt", :], 10)
10×8 DataFrame
Rowidassetmilestone_yearrep_periodtime_block_starttime_block_endunit_commitment_integersolution
Int64StringInt32Int32Int32Int32BoolFloat64
11ccgt2030111true0.0
22ccgt2030122true0.0
33ccgt2030133true0.0
44ccgt2030144true0.0
55ccgt2030155true0.0
66ccgt2030166true0.0
77ccgt2030177true0.0
88ccgt2030188true0.0
99ccgt2030199true0.0
1010ccgt203011010true0.0

The minimum-time restrictions reduce the number of switching opportunities. Compare the commitment and transition variables:

ccgt_units_on = filter(row -> row.asset == "ccgt", TIO.get_table(threevar_connection, "var_units_on"))
ccgt_start_up = filter(row -> row.asset == "ccgt", start_up)
ccgt_shut_down = filter(row -> row.asset == "ccgt", shut_down)

plot(
    ccgt_units_on.time_block_start,
    ccgt_units_on.solution;
    seriestype = :steppost,
    label = "units on",
    xlabel = "Hour",
    ylabel = "Units",
    xlims = (1, 168),
)
scatter!(
    ccgt_start_up.time_block_start,
    ccgt_start_up.solution;
    label = "start-up",
    marker = :utriangle,
)
scatter!(
    ccgt_shut_down.time_block_start,
    ccgt_shut_down.solution;
    label = "shut-down",
    marker = :dtriangle,
)
Example block output

The three modes can be compared using the objective and model size:

comparison = DataFrame(
    mode = ["none", "basic", "3var (1h up/down)", "3var (tutorial defaults)"],
    objective = [
        none_problem.objective_value,
        basic_problem.objective_value,
        threevar_hourly_problem.objective_value,
        threevar_problem.objective_value,
    ],
    variables = [
        JuMP.num_variables(none_problem.model),
        JuMP.num_variables(basic_problem.model),
        JuMP.num_variables(threevar_hourly_problem.model),
        JuMP.num_variables(threevar_problem.model),
    ],
    structural_constraints = [
        JuMP.num_constraints(p.model; count_variable_in_set_constraints = false) for p in
        (none_problem, basic_problem, threevar_hourly_problem, threevar_problem)
    ],
)
comparison
4×4 DataFrame
Rowmodeobjectivevariablesstructural_constraints
StringFloat64Int64Int64
1none3.34581e613441680
2basic4.04801e616802688
33var (1h up/down)4.04801e623523696
43var (tutorial defaults)4.0521e623523696

none is the least restrictive model. basic adds commitment and minimum-output logic, while 3var adds transition variables and minimum up/down-time logic.

Part 2: coarser commitment resolution

The resolution of units_on, start_up, and shut_down is controlled by assets_rep_periods_partitions. A partition of 4 means that one commitment variable represents a four-hour block. This can reduce the number of integer variables substantially.

The output-flow resolution is separate. If the flow remains hourly, the model can still dispatch hourly while the commitment state changes only every four hours. Coarsening both the commitment variables and the thermal output flows reduces the model further, but can change the dispatch more noticeably.

We use the hourly 3var case as the reference and measure the objective gap:

hourly_reference = threevar_problem.objective_value
partitions = [1, 2, 3, 4]

function partition_sweep(; coarsen_flows = false)
    rows = NamedTuple[]
    for partition in partitions
        connection, problem, timings = run_uc_case(
            "3var";
            asset_partition = partition,
            flow_partition = coarsen_flows ? partition : nothing,
        )
        push!(rows, (
            partition,
            objective = problem.objective_value,
            objective_gap = 100 * (problem.objective_value / hourly_reference - 1),
            variables = JuMP.num_variables(problem.model),
            structural_constraints = JuMP.num_constraints(
                problem.model;
                count_variable_in_set_constraints = false,
            ),
            units_on_rows = DataFrames.nrow(TIO.get_table(connection, "var_units_on")),
            model_build_time = timings.model_build_time,
            solver_time = timings.solver_time,
        ))
    end
    return DataFrame(rows)
end

asset_partition_results = partition_sweep()
flow_partition_results = partition_sweep(; coarsen_flows = true)
4×8 DataFrame
Rowpartitionobjectiveobjective_gapvariablesstructural_constraintsunits_on_rowsmodel_build_timesolver_time
Int64Float64Float64Int64Int64Int64Float64Float64
114.0521e60.0235236963360.8591580.425855
224.20971e63.88973168025201680.8459320.548053
334.30118e66.14697145621281120.8425380.462265
444.4292e69.3064813441932841.161750.3925

The first sweep changes only the commitment resolution. The second also coarsens the two thermal output flows. Compare the results:

asset_partition_results
4×8 DataFrame
Rowpartitionobjectiveobjective_gapvariablesstructural_constraintsunits_on_rowsmodel_build_timesolver_time
Int64Float64Float64Int64Int64Int64Float64Float64
114.0521e60.0235236963360.9064550.444043
224.07125e60.472624184830241680.8892580.312645
334.09913e61.16068168028001120.8602780.183581
444.12176e61.7191615962688840.8576390.181838
flow_partition_results
4×8 DataFrame
Rowpartitionobjectiveobjective_gapvariablesstructural_constraintsunits_on_rowsmodel_build_timesolver_time
Int64Float64Float64Int64Int64Int64Float64Float64
114.0521e60.0235236963360.8591580.425855
224.20971e63.88973168025201680.8459320.548053
334.30118e66.14697145621281120.8425380.462265
444.4292e69.3064813441932841.161750.3925

Select the largest partition whose objective gap remains below 1%:

acceptable = filter(:objective_gap => gap -> gap <= 1.0, asset_partition_results)
selected_partition = maximum(acceptable.partition)
selected_partition
2

For this case, the asset-only sweep selects partition 2. The exact value is data-dependent: a partition that is acceptable for one demand and availability profile may be too coarse for another.

plot(
    asset_partition_results.partition,
    asset_partition_results.objective_gap;
    marker = :circle,
    label = "Output flows: hourly",
    xlabel = "Partition (hours)",
    ylabel = "Objective gap (%)",
)
plot!(
    flow_partition_results.partition,
    flow_partition_results.objective_gap;
    marker = :diamond,
    label = "Output flows: same as UC partition",
)
hline!([1.0]; linestyle = :dash, label = "1% objective gap vs fully hourly model")
Example block output

A coarser partition reduces integer variables and structural constraints, but it restricts when a unit can change state. Always compare against a sufficiently resolved reference and choose the coarsest partition that preserves the accuracy needed for the study.

The same sweep also illustrates the scalability trade-off. Model-build time measures the creation of EnergyProblem and the JuMP model. Solver time is reported by the optimizer and excludes model creation and solution extraction.

timing_plot = plot(; layout = (2, 1), size = (800, 700), link = :x)
for (results, label, marker) in (
    (asset_partition_results, "Output flows: hourly", :circle),
    (flow_partition_results, "Output flows: same as UC partition", :diamond),
)
    plot!(
        timing_plot[1],
        results.partition,
        results.model_build_time;
        marker,
        label,
        ylabel = "Model-build time (s)",
    )
    plot!(
        timing_plot[2],
        results.partition,
        results.solver_time;
        marker,
        label,
        xlabel = "Partition (hours)",
        ylabel = "Solver time (s)",
    )
end
timing_plot
Example block output

These timings are illustrative and machine-dependent. The earlier examples warm up the model-building code before the sweep, while JuMP.solve_time reports time inside the optimizer and excludes Julia compilation.

Tip

The 1% objective-gap threshold is measured against the fully hourly model and is a decision criterion for this tutorial case, not a universal guarantee. Validate the selected resolution for the scenarios, assets, and objective components in your own model.

Try it yourself
  1. Change minimum_up_time and minimum_down_time and repeat the partition sweep.
  2. Set unit_commitment_integer = false for the thermal assets and compare the relaxed model size and objective.
  3. Add ramping and inspect how the flow resolution affects the ramping constraints.