Model customization

In the previous tutorial, we used UnitCommitment.jl to solve benchmark and user-provided instances using a default mathematical formulation for the problem. In this tutorial, we will explore how to customize this formulation.

Warning

This tutorial is not required for using UnitCommitment.jl, unless you plan to make changes to the problem formulation. In this page, we assume familiarity with the JuMP modeling language. Please see JuMP's official documentation for resources on getting started with JuMP.

Selecting modeling components

By default, UnitCommitment.build_model uses a formulation that combines modeling components from different publications, and that has been carefully tested, using our own benchmark scripts, to provide good performance across a wide variety of instances. This default formulation is expected to change over time, as new methods are proposed in the literature. You can, however, construct your own formulation, based on the modeling components that you choose, as shown in the next example.

We start by importing the necessary packages and reading a benchmark instance:

using HiGHS
using JuMP
using UnitCommitment

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

Next, instead of calling UnitCommitment.build_model with default arguments, we can provide a UnitCommitment.Formulation object, which describes what modeling components to use, and how should they be configured. For a complete list of modeling components available in UnitCommitment.jl, see the API docs.

In the example below, we switch to piecewise-linear cost modeling as defined in KnuOstWat2018, as well as ramping and startup costs formulation as defined in MorLatRam2013. In addition, we specify custom cutoffs for the shift factors formulation.

model = UnitCommitment.build_model(
    instance = instance,
    optimizer = HiGHS.Optimizer,
    formulation = UnitCommitment.Formulation(
        pwl_costs = UnitCommitment.KnuOstWat2018.PwlCosts(),
        ramping = UnitCommitment.MorLatRam2013.Ramping(),
        startup_costs = UnitCommitment.MorLatRam2013.StartupCosts(),
        transmission = UnitCommitment.ShiftFactorsFormulation(
            isf_cutoff = 0.008,
            lodf_cutoff = 0.003,
        ),
    ),
);
[ Info: Building model...
[ Info: Building scenario s1 with probability 1.0
[ Info: Computing injection shift factors...
[ Info: Computed ISF in 0.03 seconds
[ Info: Computing line outage factors...
[ Info: Computed LODF in 0.00 seconds
[ Info: Applying PTDF and LODF cutoffs (0.00800, 0.00300)
[ Info: Built model in 1.25 seconds

Accessing decision variables

In the previous tutorial, we saw how to access the optimal solution through UnitCommitment.solution. While this approach works well for basic usage, it is also possible to get a direct reference to the JuMP decision variables and query their values, as the next example illustrates.

First, we load a benchmark instance and solve it, as before.

instance = UnitCommitment.read_benchmark("matpower/case14/2017-01-01");
model =
    UnitCommitment.build_model(instance = instance, optimizer = HiGHS.Optimizer);
UnitCommitment.optimize!(model)
[ 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
[ Info: Setting MILP time limit to 86399.99 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      6.83613560039e-10
  Solution status   feasible
                    360642.544974 (objective)
                    0 (bound viol.)
                    0 (int. viol.)
                    0 (row viol.)
  Timing            0.11
  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.69 seconds
[ Info: No violations found

At this point, it is possible to obtain a reference to the decision variables by calling model[:varname][index]. For example, model[:is_on]["g1",1] returns a direct reference to the JuMP variable indicating whether generator named "g1" is on at time 1. For a complete list of decision variables available, and how are they indexed, see the problem definition.

@show JuMP.value(model[:is_on]["g1", 1])
1.0

To access second-stage decisions, it is necessary to specify the scenario name. UnitCommitment.jl models deterministic instances as a particular case in which there is a single scenario named "s1", so we need to use this key.

@show JuMP.value(model[:prod_above]["s1", "g1", 1])
145.16900936037393

Modifying variables and constraints

When testing variations of the unit commitment problem, it is often necessary to modify the objective function, variables and constraints of the formulation. UnitCommitment.jl makes this process relatively easy. The first step is to construct the standard model using UnitCommitment.build_model:

instance = UnitCommitment.read_benchmark("matpower/case14/2017-01-01");
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

Now, before calling UnitCommitment.optimize, we can make any desired changes to the formulation. In the previous section, we saw how to obtain a direct reference to the decision variables. It is possible to modify them by using standard JuMP methods. For example, to fix the commitment status of a particular generator, we can use JuMP.fix:

JuMP.fix(model[:is_on]["g1", 1], 1.0, force = true)

To modify the cost coefficient of a particular variable, we can use JuMP.set_objective_coefficient:

JuMP.set_objective_coefficient(model, model[:switch_on]["g1", 1], 1000.0)

It is also possible to make changes to the set of constraints. For example, we can add a custom constraint, using the JuMP.@constraint macro:

@constraint(model, model[:is_on]["g3", 1] + model[:is_on]["g4", 1] <= 1,);

We can also remove an existing model constraint using JuMP.delete. See the problem definition for a list of constraint names and indices.

JuMP.delete(model, model[:eq_min_uptime]["g1", 1])

After we are done with all changes, we can call UnitCommitment.optimize and extract the optimal solution:

UnitCommitment.optimize!(model)
@show 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}()

Modeling new grid components

In this section we demonstrate how to add a new grid component to a particular bus in the network. This is useful, for example, when developing formulations for a new type of generator, energy storage, or any other grid device. We start by reading the instance data and buliding a standard model:

instance = UnitCommitment.read_benchmark("matpower/case118/2017-02-01")
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.07 seconds
[ Info: Computing line outage factors...
[ Info: Computed LODF in 0.03 seconds
[ Info: Applying PTDF and LODF cutoffs (0.00500, 0.00100)
[ Info: Built model in 0.25 seconds

Next, we create decision variables for the new grid component. In this example, we assume that the new component can inject up to 10 MW of power at each time step, so we create new continuous variables $0 \leq x_t \leq 10$.

T = instance.time
@variable(model, x[1:T], lower_bound = 0.0, upper_bound = 10.0);

Next, we add the production costs to the objective function. In this example, we assume a generation cost of $5/MW:

for t in 1:T
    set_objective_coefficient(model, x[t], 5.0)
end

We then attach the new component to bus b1 by modifying the net injection constraint (eq_net_injection):

for t in 1:T
    set_normalized_coefficient(
        model[:eq_net_injection]["s1", "b1", t],
        x[t],
        1.0,
    )
end

Next, we solve the model:

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 46674 rows; 40536 cols; 163792 nonzeros; 11664 integer variables (11664 binary)
Coefficient ranges:
  Matrix  [6e-01, 8e+02]
  Cost    [5e+00, 6e+04]
  Bound   [1e+00, 3e+02]
  RHS     [1e+00, 6e+02]
Presolving model
43013 rows, 28322 cols, 155999 nonzeros  0s
31715 rows, 21082 cols, 121464 nonzeros  0s
31103 rows, 20793 cols, 122124 nonzeros  0s
Presolve reductions: rows 31103(-15571); columns 20793(-19743); nonzeros 122124(-41668)

Solving MIP model with:
   31103 rows
   20793 cols (9165 binary, 0 integer, 0 implied int., 11628 continuous, 0 domain fixed)
   122124 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%   21088.387251    inf                  inf        0      0      0         0     0.9s
 R       0       0         0   0.00%   5748240.007416  5753671.319007     0.09%        0      0      0      8244     1.3s

39.3% inactive integer columns, restarting
Model after restart has 24147 rows, 16342 cols (4969 bin., 0 int., 0 impl., 11373 cont., 0 dom.fix.), and 89552 nonzeros

         0       0         0   0.00%   5748240.007416  5753671.319007     0.09%        0      0      0      8244     1.5s
         0       0         0   0.00%   5748240.007416  5753671.319007     0.09%        0      0      0     10047     1.7s
 T       0       0         0   0.00%   5748240.007416  5748243.094566     0.00%      298     38      0     10060     1.8s
         1       0         1 100.00%   5748243.094566  5748243.094566     0.00%      298     38      0     10060     1.8s

Solving report
  Status            Optimal
  Primal bound      5748243.09457
  Dual bound        5748243.09457
  Gap               0% (tolerance: 0.01%)
  P-D integral      0.000527870269766
  Solution status   feasible
                    5748243.09457 (objective)
                    0 (bound viol.)
                    4.52970994047e-14 (int. viol.)
                    0 (row viol.)
  Timing            1.83
  Max sub-MIP depth 0
  Nodes             1
  Repair LPs        0
  LP iterations     10060
                    0 (strong br.)
                    13 (separation)
                    0 (heuristics)
[ Info: Verifying transmission limits...
[ Info: Verified transmission limits in 0.01 seconds
[ Info: No violations found

We then finally extract the optimal value of the $x$ variables:

@show value.(x)
36-element Vector{Float64}:
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
  ⋮
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0
 10.0