Getting started

Installing the package

UnitCommitment.jl was tested and developed with Julia 1.10. To install Julia, please follow the installation guide on the official Julia website. To install UnitCommitment.jl, run the Julia interpreter, type ] to open the package manager, then type:

pkg> add UnitCommitment@0.4

To solve the optimization models, a mixed-integer linear programming (MILP) solver is also required. Please see the JuMP installation guide for more instructions on installing a solver. Typical open-source choices are HiGHS, Cbc and GLPK. In the instructions below, HiGHS will be used, but any other MILP solver should also be compatible.

Solving a benchmark instance

We start this tutorial by illustrating how to use UnitCommitment.jl to solve one of the provided benchmark instances. The package contains a large number of deterministic benchmark instances collected from the literature and converted into a common data format, which can be used to evaluate the performance of different solution methods. See Instances for more details. The first step is to import UnitCommitment and HiGHS.

using HiGHS
using UnitCommitment

Next, we use the function UnitCommitment.read_benchmark to read the instance.

instance = UnitCommitment.read_benchmark("matpower/case14/2017-01-01");

Now that we have the instance loaded in memory, we build the JuMP optimization model using UnitCommitment.build_model:

model =
    UnitCommitment.build_model(instance = instance, optimizer = HiGHS.Optimizer);
[ Info: Building model...
[ Info: Building scenario s1 with probability 1.0
[ Info: Computing injection shift factors...
[ Info: Computed ISF in 0.00 seconds
[ Info: Computing line outage factors...
[ Info: Computed LODF in 0.00 seconds
[ Info: Applying PTDF and LODF cutoffs (0.00500, 0.00100)
[ Info: Built model in 0.01 seconds

Next, we run the optimization process, with UnitCommitment.optimize!:

UnitCommitment.optimize!(model)
[ Info: Setting MILP time limit to 86400.00 seconds
[ Info: Solving MILP...
Running HiGHS 1.12.0 (git hash: 755a8e027a): Copyright (c) 2025 HiGHS under MIT licence terms
MIP has 4744 rows; 4104 cols; 15633 nonzeros; 1080 integer variables (1080 binary)
Coefficient ranges:
  Matrix  [1e+00, 3e+02]
  Cost    [3e+01, 3e+04]
  Bound   [1e+00, 1e+02]
  RHS     [1e+00, 4e+02]
Presolving model
4382 rows, 2704 cols, 14776 nonzeros  0s
3177 rows, 1985 cols, 11301 nonzeros  0s
3148 rows, 1965 cols, 11570 nonzeros  0s
Presolve reductions: rows 3148(-1596); columns 1965(-2139); nonzeros 11570(-4063)

Solving MIP model with:
   3148 rows
   1965 cols (862 binary, 0 integer, 0 implied int., 1103 continuous, 0 domain fixed)
   11570 nonzeros

Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
     I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
     S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
     Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero

        Nodes      |    B&B Tree     |            Objective Bounds              |  Dynamic Constraints |       Work
Src  Proc. InQueue |  Leaves   Expl. | BestBound       BestSol              Gap |   Cuts   InLp Confl. | LpIters     Time

         0       0         0   0.00%   1.86264515e-09  inf                  inf        0      0      0         0     0.1s
 R       0       0         0   0.00%   360642.328869   360642.544974      0.00%        0      0      0       958     0.1s
         1       0         1 100.00%   360642.328869   360642.544974      0.00%        0      0      0       958     0.1s

Solving report
  Status            Optimal
  Primal bound      360642.544974
  Dual bound        360642.328869
  Gap               6e-05% (tolerance: 0.01%)
  P-D integral      4.76315069837e-10
  Solution status   feasible
                    360642.544974 (objective)
                    0 (bound viol.)
                    0 (int. viol.)
                    0 (row viol.)
  Timing            0.10
  Max sub-MIP depth 0
  Nodes             1
  Repair LPs        0
  LP iterations     958
                    0 (strong br.)
                    0 (separation)
                    0 (heuristics)
[ Info: Verifying transmission limits...
[ Info: Verified transmission limits in 0.00 seconds
[ Info: No violations found

Finally, we extract the optimal solution from the model:

solution = UnitCommitment.solution(model)
OrderedCollections.OrderedDict{Any, Any} with 15 entries:
  "Thermal production (MW)"         => OrderedDict("g1"=>[181.92, 172.85, 166.8…
  "Thermal production cost (\$)"    => OrderedDict("g1"=>[7241.95, 6839.01, 657…
  "Startup cost (\$)"               => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Is on"                           => OrderedDict("g1"=>[1.0, 1.0, 1.0, 1.0, 1…
  "Switch on"                       => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Switch off"                      => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Net injection (MW)"              => OrderedDict("b1"=>[181.92, 172.85, 166.8…
  "Load curtail (MW)"               => OrderedDict("b1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Line overflow (MW)"              => OrderedDict("l1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Spinning reserve (MW)"           => OrderedDict("r1"=>OrderedDict("g1"=>[4.6…
  "Spinning reserve shortfall (MW)" => OrderedDict("r1"=>[0.0, 0.0, 0.0, 0.0, 0…
  "Up-flexiramp (MW)"               => OrderedDict{Any, Any}()
  "Up-flexiramp shortfall (MW)"     => OrderedDict{Any, Any}()
  "Down-flexiramp (MW)"             => OrderedDict{Any, Any}()
  "Down-flexiramp shortfall (MW)"   => OrderedDict{Any, Any}()

We can then explore the solution using Julia:

@show solution["Thermal production (MW)"]["g1"]
36-element Vector{Float64}:
 181.92024301829747
 172.8503730182975
 166.80678301829755
 163.23845301829746
 165.5149530182975
 169.95052301829747
 182.3719130182975
 191.4327730182975
 196.6723430182975
 202.53524301829748
   ⋮
 165.0181030182975
 167.2404030182975
 180.89039301829752
 196.9523830182975
 206.5552530182975
 214.18877301829752
 225.48092301829752
 226.8179230182975
 222.72565301829746

Or export the entire solution to a JSON file:

UnitCommitment.write("solution.json", solution)

Solving a custom deterministic instance

In the previous example, we solved a benchmark instance provided by the package. To solve a custom instance, the first step is to create an input file describing the list of elements (generators, loads and transmission lines) in the network. See Data Format for a complete description of the data format UC.jl expects. To keep this tutorial self-contained, we will create the input JSON file using Julia; however, this step can also be done with a simple text editor. First, we define the contents of the file:

json_contents = """
{
    "Parameters": {
        "Version": "0.4",
        "Time horizon (h)": 4
    },
    "Buses": {
        "b1": {
            "Load (MW)": [100, 150, 200, 250]
        }
    },
    "Generators": {
        "g1": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 200],
            "Production cost curve (\$)": [0, 1000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        },
        "g2": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 300],
            "Production cost curve (\$)": [0, 3000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        }
    }
}
""";

Next, we write it to example.json.

open("example.json", "w") do file
    return write(file, json_contents)
end;

Now that we have the input file, we can proceed as before, but using UnitCommitment.read instead of UnitCommitment.read_benchmark:

instance = UnitCommitment.read("example.json");
model =
    UnitCommitment.build_model(instance = instance, optimizer = HiGHS.Optimizer);
UnitCommitment.optimize!(model)
[ Info: Building model...
[ Info: Building scenario s1 with probability 1.0
[ Info: Built model in 0.00 seconds
[ Info: Setting MILP time limit to 86400.00 seconds
[ Info: Solving MILP...
Running HiGHS 1.12.0 (git hash: 755a8e027a): Copyright (c) 2025 HiGHS under MIT licence terms
MIP has 108 rows; 64 cols; 230 nonzeros; 32 integer variables (32 binary)
Coefficient ranges:
  Matrix  [1e+00, 3e+02]
  Cost    [5e+00, 1e+03]
  Bound   [1e+00, 3e+02]
  RHS     [1e+00, 1e+06]
Presolving model
74 rows, 36 cols, 166 nonzeros  0s
47 rows, 21 cols, 121 nonzeros  0s
28 rows, 12 cols, 78 nonzeros  0s
0 rows, 0 cols, 0 nonzeros  0s
Presolve reductions: rows 0(-108); columns 0(-64); nonzeros 0(-230) - Reduced to empty
Presolve: Optimal

Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
     I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
     S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
     Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero

        Nodes      |    B&B Tree     |            Objective Bounds              |  Dynamic Constraints |       Work
Src  Proc. InQueue |  Leaves   Expl. | BestBound       BestSol              Gap |   Cuts   InLp Confl. | LpIters     Time

         0       0         0   0.00%   3750            3750               0.00%        0      0      0         0     0.0s

Solving report
  Status            Optimal
  Primal bound      3750
  Dual bound        3750
  Gap               0% (tolerance: 0.01%)
  P-D integral      0
  Solution status   feasible
                    3750 (objective)
                    0 (bound viol.)
                    0 (int. viol.)
                    0 (row viol.)
  Timing            0.00
  Max sub-MIP depth 0
  Nodes             0
  Repair LPs        0
  LP iterations     0

Finally, we extract and display the solution:

solution = UnitCommitment.solution(model)
OrderedCollections.OrderedDict{Any, Any} with 14 entries:
  "Thermal production (MW)"         => OrderedDict("g1"=>[100.0, 150.0, 200.0, …
  "Thermal production cost (\$)"    => OrderedDict("g1"=>[500.0, 750.0, 1000.0,…
  "Startup cost (\$)"               => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0], …
  "Is on"                           => OrderedDict("g1"=>[1.0, 1.0, 1.0, 1.0], …
  "Switch on"                       => OrderedDict("g1"=>[1.0, 0.0, 0.0, 0.0], …
  "Switch off"                      => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0], …
  "Net injection (MW)"              => OrderedDict("b1"=>[0.0, 0.0, 0.0, 0.0])
  "Load curtail (MW)"               => OrderedDict("b1"=>[0.0, 0.0, 0.0, 0.0])
  "Spinning reserve (MW)"           => OrderedDict{Any, Any}()
  "Spinning reserve shortfall (MW)" => OrderedDict{Any, Any}()
  "Up-flexiramp (MW)"               => OrderedDict{Any, Any}()
  "Up-flexiramp shortfall (MW)"     => OrderedDict{Any, Any}()
  "Down-flexiramp (MW)"             => OrderedDict{Any, Any}()
  "Down-flexiramp shortfall (MW)"   => OrderedDict{Any, Any}()
@show solution["Thermal production (MW)"]["g1"]
4-element Vector{Float64}:
 100.0
 150.0
 200.0
 200.0
@show solution["Thermal production (MW)"]["g2"]
4-element Vector{Float64}:
  0.0
  0.0
  0.0
 50.0

Solving a custom stochastic instance

In addition to deterministic test cases, UnitCommitment.jl can also solve two-stage stochastic instances of the problem. In this section, we demonstrate the most simple form, which builds a single (extensive form) model containing information for all scenarios. See Decomposition for more advanced methods.

First, we need to create one JSON input file for each scenario. Parameters that are allowed to change across scenarios are marked as "uncertain" in the JSON data format page. It is also possible to specify the name and weight of each scenario, as shown below.

We start by creating example_s1.json, the first scenario file:

json_contents_s1 = """
{
    "Parameters": {
        "Version": "0.4",
        "Time horizon (h)": 4,
        "Scenario name": "s1",
        "Scenario weight": 3.0
    },
    "Buses": {
        "b1": {
            "Load (MW)": [100, 150, 200, 250]
        }
    },
    "Generators": {
        "g1": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 200],
            "Production cost curve (\$)": [0, 1000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        },
        "g2": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 300],
            "Production cost curve (\$)": [0, 3000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        }
    }
}
"""
open("example_s1.json", "w") do file
    return write(file, json_contents_s1)
end;

Next, we create example_s2.json, the second scenario file:

json_contents_s2 = """
{
    "Parameters": {
        "Version": "0.4",
        "Time horizon (h)": 4,
        "Scenario name": "s2",
        "Scenario weight": 1.0
    },
    "Buses": {
        "b1": {
            "Load (MW)": [200, 300, 400, 500]
        }
    },
    "Generators": {
        "g1": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 200],
            "Production cost curve (\$)": [0, 1000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        },
        "g2": {
            "Bus": "b1",
            "Type": "Thermal",
            "Production cost curve (MW)": [0, 300],
            "Production cost curve (\$)": [0, 3000],
            "Initial status (h)": -24,
            "Initial power (MW)": 0
        }
    }
}
""";
open("example_s2.json", "w") do file
    return write(file, json_contents_s2)
end;

Now that we have our two scenario files, we can read them using UnitCommitment.read. Note that, instead of a single file, we now provide a list.

instance = UnitCommitment.read(["example_s1.json", "example_s2.json"])
UnitCommitmentInstance(2 scenarios, 2 thermal units, 0 profiled units, 1 buses, 0 lines, 0 contingencies, 0 price sensitive loads, 4 time steps)

If we have a large number of scenario files, the Glob package can also be used to avoid having to list them individually:

using Glob
instance = UnitCommitment.read(glob("example_s*.json"))
UnitCommitmentInstance(2 scenarios, 2 thermal units, 0 profiled units, 1 buses, 0 lines, 0 contingencies, 0 price sensitive loads, 4 time steps)

Finally, we build the model and optimize as before:

model =
    UnitCommitment.build_model(instance = instance, optimizer = HiGHS.Optimizer);
UnitCommitment.optimize!(model)
[ Info: Building model...
[ Info: Building scenario s1 with probability 0.75
[ Info: Building scenario s2 with probability 0.25
[ Info: Built model in 0.01 seconds
[ Info: Setting MILP time limit to 86400.00 seconds
[ Info: Solving MILP...
Running HiGHS 1.12.0 (git hash: 755a8e027a): Copyright (c) 2025 HiGHS under MIT licence terms
MIP has 174 rows; 96 cols; 366 nonzeros; 32 integer variables (32 binary)
Coefficient ranges:
  Matrix  [1e+00, 3e+02]
  Cost    [1e+00, 8e+02]
  Bound   [1e+00, 5e+02]
  RHS     [1e+00, 1e+06]
Presolving model
115 rows, 47 cols, 251 nonzeros  0s
47 rows, 43 cols, 113 nonzeros  0s
31 rows, 29 cols, 96 nonzeros  0s
5 rows, 9 cols, 13 nonzeros  0s
Presolve reductions: rows 5(-169); columns 9(-87); nonzeros 13(-353)

Solving MIP model with:
   5 rows
   9 cols (3 binary, 0 integer, 0 implied int., 6 continuous, 0 domain fixed)
   13 nonzeros

Src: B => Branching; C => Central rounding; F => Feasibility pump; H => Heuristic;
     I => Shifting; J => Feasibility jump; L => Sub-MIP; P => Empty MIP; R => Randomized rounding;
     S => Solve LP; T => Evaluate node; U => Unbounded; X => User solution; Y => HiGHS solution;
     Z => ZI Round; l => Trivial lower; p => Trivial point; u => Trivial upper; z => Trivial zero

        Nodes      |    B&B Tree     |            Objective Bounds              |  Dynamic Constraints |       Work
Src  Proc. InQueue |  Leaves   Expl. | BestBound       BestSol              Gap |   Cuts   InLp Confl. | LpIters     Time

 J       0       0         0   0.00%   -inf            30312.5            Large        0      0      0         0     0.0s
 T       0       0         0   0.00%   4300            5312.5            19.06%        0      0      0         0     0.0s
         1       0         1 100.00%   5312.5          5312.5             0.00%        0      0      0         0     0.0s

Solving report
  Status            Optimal
  Primal bound      5312.5
  Dual bound        5312.5
  Gap               0% (tolerance: 0.01%)
  P-D integral      0.000122052910402
  Solution status   feasible
                    5312.5 (objective)
                    0 (bound viol.)
                    0 (int. viol.)
                    0 (row viol.)
  Timing            0.00
  Max sub-MIP depth 0
  Nodes             1
  Repair LPs        0
  LP iterations     0

The solution to stochastic instances follows a slightly different format, as shown below:

solution = UnitCommitment.solution(model)
OrderedCollections.OrderedDict{Any, Any} with 2 entries:
  "s1" => OrderedDict{Any, Any}("Thermal production (MW)"=>OrderedDict("g1"=>[1…
  "s2" => OrderedDict{Any, Any}("Thermal production (MW)"=>OrderedDict("g1"=>[2…

The solution for each scenario can be accessed through solution[scenario_name]. For conveniance, this includes both first- and second-stage optimal decisions:

solution["s1"]
OrderedCollections.OrderedDict{Any, Any} with 14 entries:
  "Thermal production (MW)"         => OrderedDict("g1"=>[100.0, 150.0, 200.0, …
  "Thermal production cost (\$)"    => OrderedDict("g1"=>[500.0, 750.0, 1000.0,…
  "Startup cost (\$)"               => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0], …
  "Is on"                           => OrderedDict("g1"=>[1.0, 1.0, 1.0, 1.0], …
  "Switch on"                       => OrderedDict("g1"=>[1.0, 0.0, 0.0, 0.0], …
  "Switch off"                      => OrderedDict("g1"=>[0.0, 0.0, 0.0, 0.0], …
  "Net injection (MW)"              => OrderedDict("b1"=>[0.0, 0.0, 0.0, 0.0])
  "Load curtail (MW)"               => OrderedDict("b1"=>[0.0, 0.0, 0.0, 0.0])
  "Spinning reserve (MW)"           => OrderedDict{Any, Any}()
  "Spinning reserve shortfall (MW)" => OrderedDict{Any, Any}()
  "Up-flexiramp (MW)"               => OrderedDict{Any, Any}()
  "Up-flexiramp shortfall (MW)"     => OrderedDict{Any, Any}()
  "Down-flexiramp (MW)"             => OrderedDict{Any, Any}()
  "Down-flexiramp shortfall (MW)"   => OrderedDict{Any, Any}()