Merge branch 'dev' into feature/reserves

feature/reserves
Alinson S. Xavier 3 years ago
commit dc693896a3

@ -9,7 +9,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
julia-version: ['1.4', '1.5', '1.6'] julia-version: ['1.6', '1.7']
julia-arch: [x64] julia-arch: [x64]
os: [ubuntu-latest, windows-latest, macOS-latest] os: [ubuntu-latest, windows-latest, macOS-latest]
exclude: exclude:

31
.gitignore vendored

@ -1,21 +1,38 @@
*.bak *.bak
*.gz *.gz
*.ipynb
*.lastrun *.lastrun
*.so
*.mps *.mps
*.ipynb *.so
*/Manifest.toml
.AppleDB
.AppleDesktop
.AppleDouble
.DS_Store
.DocumentRevisions-V100
.LSOverride
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
._*
.apdisk
.com.apple.timemachine.donotpresent
.fseventsd
.ipy* .ipy*
.vscode
Icon
Manifest.toml
Network Trash Folder
TODO.md
Temporary Items
benchmark/results benchmark/results
benchmark/runs benchmark/runs
benchmark/tables benchmark/tables
benchmark/tmp.json benchmark/tmp.json
build build
docs/_build
instances/**/*.json instances/**/*.json
instances/_source instances/_source
local local
notebooks notebooks
TODO.md
docs/_build
.vscode
Manifest.toml
*/Manifest.toml

@ -5,7 +5,7 @@
VERSION := 0.2 VERSION := 0.2
clean: clean:
rm -rfv build rm -rfv build Manifest.toml test/Manifest.toml deps/formatter/build deps/formatter/Manifest.toml
docs: docs:
cd docs; make clean; make dirhtml cd docs; make clean; make dirhtml

@ -25,7 +25,7 @@ DataStructures = "0.18"
Distributions = "0.25" Distributions = "0.25"
GZip = "0.5" GZip = "0.5"
JSON = "0.21" JSON = "0.21"
JuMP = "0.21" JuMP = "1"
MathOptInterface = "0.9" MathOptInterface = "1"
PackageCompiler = "1" PackageCompiler = "1"
julia = "1" julia = "1"

@ -95,6 +95,7 @@ UnitCommitment.write("/tmp/output.json", solution)
## Authors ## Authors
* **Alinson S. Xavier** (Argonne National Laboratory) * **Alinson S. Xavier** (Argonne National Laboratory)
* **Aleksandr M. Kazachkov** (University of Florida) * **Aleksandr M. Kazachkov** (University of Florida)
* **Ogün Yurdakul** (Technische Universität Berlin)
* **Feng Qiu** (Argonne National Laboratory) * **Feng Qiu** (Argonne National Laboratory)
## Acknowledgments ## Acknowledgments

@ -209,17 +209,17 @@ This section describes the hourly amount of reserves required.
| Key | Description | Default | Time series? | Key | Description | Default | Time series?
| :-------------------- | :------------------------------------------------- | --------- | :----: | :-------------------- | :------------------------------------------------- | --------- | :----:
| `Type` | Type of reserve product. Currently, only `Spinning` is supported. | Required | N | `Type` | Type of reserve product. Must be either "spinning" or "flexiramp". | Required | N
| `Amount (MW)` | Amount of reserves required. | Required | Y | `Amount (MW)` | Amount of reserves required. | Required | Y
| `Shortfall penalty ($/MW)` | Penalty for shortage in meeting the reserve requirements (in $/MW). This is charged per time step. Negative value implies reserve constraints must always be satisfied. | `-1` | Y | `Shortfall penalty ($/MW)` | Penalty for shortage in meeting the reserve requirements (in $/MW). This is charged per time step. Negative value implies reserve constraints must always be satisfied. | `-1` | Y
#### Example #### Example 1
```json ```json
{ {
"Reserves": { "Reserves": {
"r1": { "r1": {
"Type": "Spinning", "Type": "spinning",
"Amount (MW)": [ "Amount (MW)": [
57.30552, 57.30552,
53.88429, 53.88429,
@ -227,6 +227,15 @@ This section describes the hourly amount of reserves required.
50.46307 50.46307
], ],
"Shortfall penalty ($/MW)": 5.0 "Shortfall penalty ($/MW)": 5.0
},
"r2": {
"Type": "flexiramp",
"Amount (MW)": [
20.31042,
23.65273,
27.41784,
25.34057
],
} }
} }
} }
@ -293,3 +302,4 @@ Current limitations
* Network topology remains the same for all time periods * Network topology remains the same for all time periods
* Only N-1 transmission contingencies are supported. Generator contingencies are not currently supported. * Only N-1 transmission contingencies are supported. Generator contingencies are not currently supported.
* Time-varying minimum production amounts are not currently compatible with ramp/startup/shutdown limits. * Time-varying minimum production amounts are not currently compatible with ramp/startup/shutdown limits.
* Flexible ramping products can only be acquired under the `WanHob2016` formulation, which does not support spinning reserves.

@ -47,7 +47,14 @@ project = TOML.parsefile("Project.toml")
manifest = TOML.parsefile("Manifest.toml") manifest = TOML.parsefile("Manifest.toml")
deps = Symbol[] deps = Symbol[]
for dep in keys(project["deps"]) for dep in keys(project["deps"])
if "path" in keys(manifest[dep][1]) if dep in keys(manifest)
# Up to Julia 1.6
dep_entry = manifest[dep][1]
else
# Julia 1.7+
dep_entry = manifest["deps"][dep][1]
end
if "path" in keys(dep_entry)
println(" - \$(dep) [skip]") println(" - \$(dep) [skip]")
else else
println(" - \$(dep)") println(" - \$(dep)")

@ -16,6 +16,7 @@ include("model/formulations/KnuOstWat2018/structs.jl")
include("model/formulations/MorLatRam2013/structs.jl") include("model/formulations/MorLatRam2013/structs.jl")
include("model/formulations/PanGua2016/structs.jl") include("model/formulations/PanGua2016/structs.jl")
include("solution/methods/XavQiuWanThi2019/structs.jl") include("solution/methods/XavQiuWanThi2019/structs.jl")
include("model/formulations/WanHob2016/structs.jl")
include("import/egret.jl") include("import/egret.jl")
include("instance/read.jl") include("instance/read.jl")
@ -36,6 +37,7 @@ include("model/formulations/KnuOstWat2018/pwlcosts.jl")
include("model/formulations/MorLatRam2013/ramp.jl") include("model/formulations/MorLatRam2013/ramp.jl")
include("model/formulations/MorLatRam2013/scosts.jl") include("model/formulations/MorLatRam2013/scosts.jl")
include("model/formulations/PanGua2016/ramp.jl") include("model/formulations/PanGua2016/ramp.jl")
include("model/formulations/WanHob2016/ramp.jl")
include("model/jumpext.jl") include("model/jumpext.jl")
include("solution/fix.jl") include("solution/fix.jl")
include("solution/methods/XavQiuWanThi2019/enforce.jl") include("solution/methods/XavQiuWanThi2019/enforce.jl")

@ -119,6 +119,11 @@ function _from_json(json; repair = true)
json["Parameters"]["Power balance penalty (\$/MW)"], json["Parameters"]["Power balance penalty (\$/MW)"],
default = [1000.0 for t in 1:T], default = [1000.0 for t in 1:T],
) )
# Penalty price for shortage in meeting system-wide flexiramp requirements
flexiramp_shortfall_penalty = timeseries(
json["Parameters"]["Flexiramp penalty (\$/MW)"],
default = [500.0 for t in 1:T],
)
shortfall_penalty = timeseries( shortfall_penalty = timeseries(
json["Parameters"]["Reserve shortfall penalty (\$/MW)"], json["Parameters"]["Reserve shortfall penalty (\$/MW)"],
default = [-1.0 for t in 1:T], default = [-1.0 for t in 1:T],
@ -317,6 +322,7 @@ function _from_json(json; repair = true)
reserves = reserves, reserves = reserves,
reserves_by_name = name_to_reserve, reserves_by_name = name_to_reserve,
shortfall_penalty = shortfall_penalty, shortfall_penalty = shortfall_penalty,
flexiramp_shortfall_penalty = flexiramp_shortfall_penalty,
time = T, time = T,
units_by_name = Dict(g.name => g for g in units), units_by_name = Dict(g.name => g for g in units),
units = units, units = units,

@ -86,6 +86,7 @@ Base.@kwdef mutable struct UnitCommitmentInstance
reserves::Vector{Reserve} reserves::Vector{Reserve}
reserves_by_name::Dict{AbstractString,Reserve} reserves_by_name::Dict{AbstractString,Reserve}
shortfall_penalty::Vector{Float64} shortfall_penalty::Vector{Float64}
flexiramp_shortfall_penalty::Vector{Float64}
time::Int time::Int
units_by_name::Dict{AbstractString,Unit} units_by_name::Dict{AbstractString,Unit}
units::Vector{Unit} units::Vector{Unit}

@ -0,0 +1,168 @@
# UnitCommitmentFL.jl: Optimization Package for Security-Constrained Unit Commitment
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
::Gar1962.ProdVars,
::WanHob2016.Ramping,
::Gar1962.StatusVars,
)::Nothing
is_initially_on = (g.initial_status > 0)
SU = g.startup_limit
SD = g.shutdown_limit
RU = g.ramp_up_limit
RD = g.ramp_down_limit
gn = g.name
minp = g.min_power
maxp = g.max_power
initial_power = g.initial_power
is_on = model[:is_on]
prod_above = model[:prod_above]
upflexiramp = model[:upflexiramp]
dwflexiramp = model[:dwflexiramp]
mfg = model[:mfg]
if length(g.reserves) > 1
error("Each generator may only provide one flexiramp reserve")
end
for r in g.reserves
if r.type !== "flexiramp"
error("This formulation only supports flexiramp reserves, not $(r.type)")
end
rn = r.name
for t in 1:model[:instance].time
@constraint(
model,
prod_above[gn, t] + (is_on[gn, t] * minp[t]) <= mfg[rn, gn, t]
) # Eq. (19) in Wang & Hobbs (2016)
@constraint(model, mfg[rn, gn, t] <= is_on[gn, t] * maxp[t]) # Eq. (22) in Wang & Hobbs (2016)
if t != model[:instance].time
@constraint(
model,
minp[t] * (is_on[gn, t+1] + is_on[gn, t] - 1) <=
prod_above[gn, t] - dwflexiramp[rn, gn, t] +
(is_on[gn, t] * minp[t])
) # first inequality of Eq. (20) in Wang & Hobbs (2016)
@constraint(
model,
prod_above[gn, t] - dwflexiramp[rn, gn, t] +
(is_on[gn, t] * minp[t]) <=
mfg[rn, gn, t+1] + (maxp[t] * (1 - is_on[gn, t+1]))
) # second inequality of Eq. (20) in Wang & Hobbs (2016)
@constraint(
model,
minp[t] * (is_on[gn, t+1] + is_on[gn, t] - 1) <=
prod_above[gn, t] +
upflexiramp[rn, gn, t] +
(is_on[gn, t] * minp[t])
) # first inequality of Eq. (21) in Wang & Hobbs (2016)
@constraint(
model,
prod_above[gn, t] +
upflexiramp[rn, gn, t] +
(is_on[gn, t] * minp[t]) <=
mfg[rn, gn, t+1] + (maxp[t] * (1 - is_on[gn, t+1]))
) # second inequality of Eq. (21) in Wang & Hobbs (2016)
if t != 1
@constraint(
model,
mfg[rn, gn, t] <=
prod_above[gn, t-1] +
(is_on[gn, t-1] * minp[t]) +
(RU * is_on[gn, t-1]) +
(SU * (is_on[gn, t] - is_on[gn, t-1])) +
maxp[t] * (1 - is_on[gn, t])
) # Eq. (23) in Wang & Hobbs (2016)
@constraint(
model,
(prod_above[gn, t-1] + (is_on[gn, t-1] * minp[t])) -
(prod_above[gn, t] + (is_on[gn, t] * minp[t])) <=
RD * is_on[gn, t] +
SD * (is_on[gn, t-1] - is_on[gn, t]) +
maxp[t] * (1 - is_on[gn, t-1])
) # Eq. (25) in Wang & Hobbs (2016)
else
@constraint(
model,
mfg[rn, gn, t] <=
initial_power +
(RU * is_initially_on) +
(SU * (is_on[gn, t] - is_initially_on)) +
maxp[t] * (1 - is_on[gn, t])
) # Eq. (23) in Wang & Hobbs (2016) for the first time period
@constraint(
model,
initial_power -
(prod_above[gn, t] + (is_on[gn, t] * minp[t])) <=
RD * is_on[gn, t] +
SD * (is_initially_on - is_on[gn, t]) +
maxp[t] * (1 - is_initially_on)
) # Eq. (25) in Wang & Hobbs (2016) for the first time period
end
@constraint(
model,
mfg[rn, gn, t] <=
(SD * (is_on[gn, t] - is_on[gn, t+1])) +
(maxp[t] * is_on[gn, t+1])
) # Eq. (24) in Wang & Hobbs (2016)
@constraint(
model,
-RD * is_on[gn, t+1] - SD * (is_on[gn, t] - is_on[gn, t+1]) -
maxp[t] * (1 - is_on[gn, t]) <= upflexiramp[rn, gn, t]
) # first inequality of Eq. (26) in Wang & Hobbs (2016)
@constraint(
model,
upflexiramp[rn, gn, t] <=
RU * is_on[gn, t] +
SU * (is_on[gn, t+1] - is_on[gn, t]) +
maxp[t] * (1 - is_on[gn, t+1])
) # second inequality of Eq. (26) in Wang & Hobbs (2016)
@constraint(
model,
-RU * is_on[gn, t] - SU * (is_on[gn, t+1] - is_on[gn, t]) -
maxp[t] * (1 - is_on[gn, t+1]) <= dwflexiramp[rn, gn, t]
) # first inequality of Eq. (27) in Wang & Hobbs (2016)
@constraint(
model,
dwflexiramp[rn, gn, t] <=
RD * is_on[gn, t+1] +
SD * (is_on[gn, t] - is_on[gn, t+1]) +
maxp[t] * (1 - is_on[gn, t])
) # second inequality of Eq. (27) in Wang & Hobbs (2016)
@constraint(
model,
-maxp[t] * is_on[gn, t] + minp[t] * is_on[gn, t+1] <=
upflexiramp[rn, gn, t]
) # first inequality of Eq. (28) in Wang & Hobbs (2016)
@constraint(model, upflexiramp[rn, gn, t] <= maxp[t] * is_on[gn, t+1]) # second inequality of Eq. (28) in Wang & Hobbs (2016)
@constraint(model, -maxp[t] * is_on[gn, t+1] <= dwflexiramp[rn, gn, t]) # first inequality of Eq. (29) in Wang & Hobbs (2016)
@constraint(
model,
dwflexiramp[rn, gn, t] <=
(maxp[t] * is_on[gn, t]) - (minp[t] * is_on[gn, t+1])
) # second inequality of Eq. (29) in Wang & Hobbs (2016)
else
@constraint(
model,
mfg[rn, gn, t] <=
prod_above[gn, t-1] +
(is_on[gn, t-1] * minp[t]) +
(RU * is_on[gn, t-1]) +
(SU * (is_on[gn, t] - is_on[gn, t-1])) +
maxp[t] * (1 - is_on[gn, t])
) # Eq. (23) in Wang & Hobbs (2016) for the last time period
@constraint(
model,
(prod_above[gn, t-1] + (is_on[gn, t-1] * minp[t])) -
(prod_above[gn, t] + (is_on[gn, t] * minp[t])) <=
RD * is_on[gn, t] +
SD * (is_on[gn, t-1] - is_on[gn, t]) +
maxp[t] * (1 - is_on[gn, t-1])
) # Eq. (25) in Wang & Hobbs (2016) for the last time period
end
end
end
end

@ -0,0 +1,17 @@
# UnitCommitmentFL.jl: Optimization Package for Security-Constrained Unit Commitment
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
"""
Formulation described in:
B. Wang and B. F. Hobbs, "Real-Time Markets for Flexiramp: A Stochastic
Unit Commitment-Based Analysis," in IEEE Transactions on Power Systems,
vol. 31, no. 2, pp. 846-860, March 2016, doi: 10.1109/TPWRS.2015.2411268.
"""
module WanHob2016
import ..RampingFormulation
struct Ramping <: RampingFormulation end
end

@ -4,7 +4,8 @@
function _add_system_wide_eqs!(model::JuMP.Model)::Nothing function _add_system_wide_eqs!(model::JuMP.Model)::Nothing
_add_net_injection_eqs!(model) _add_net_injection_eqs!(model)
_add_reserve_eqs!(model) _add_spinning_reserve_eqs!(model)
_add_flexiramp_reserve_eqs!(model)
return return
end end
@ -27,16 +28,17 @@ function _add_net_injection_eqs!(model::JuMP.Model)::Nothing
return return
end end
function _add_reserve_eqs!(model::JuMP.Model)::Nothing function _add_spinning_reserve_eqs!(model::JuMP.Model)::Nothing
instance = model[:instance] instance = model[:instance]
eq_min_reserve = _init(model, :eq_min_reserve) eq_min_spinning_reserve = _init(model, :eq_min_spinning_reserve)
for r in instance.reserves for r in instance.reserves
r.type == "spinning" || continue
for t in 1:instance.time for t in 1:instance.time
# Equation (68) in Kneuven et al. (2020) # Equation (68) in Kneuven et al. (2020)
# As in Morales-España et al. (2013a) # As in Morales-España et al. (2013a)
# Akin to the alternative formulation with max_power_avail # Akin to the alternative formulation with max_power_avail
# from Carrión and Arroyo (2006) and Ostrowski et al. (2012) # from Carrión and Arroyo (2006) and Ostrowski et al. (2012)
eq_min_reserve[r.name, t] = @constraint( eq_min_spinning_reserve[r.name, t] = @constraint(
model, model,
sum(model[:reserve][r.name, g.name, t] for g in r.units) + sum(model[:reserve][r.name, g.name, t] for g in r.units) +
model[:reserve_shortfall][r.name, t] >= r.amount[t] model[:reserve_shortfall][r.name, t] >= r.amount[t]
@ -54,3 +56,44 @@ function _add_reserve_eqs!(model::JuMP.Model)::Nothing
end end
return return
end end
function _add_flexiramp_reserve_eqs!(model::JuMP.Model)::Nothing
# Note: The flexpramp requirements in Wang & Hobbs (2016) are imposed as hard constraints
# through Eq. (17) and Eq. (18). The constraints eq_min_upflexiramp and eq_min_dwflexiramp
# provided below are modified versions of Eq. (17) and Eq. (18), respectively, in that
# they include slack variables for flexiramp shortfall, which are penalized in the
# objective function.
eq_min_upflexiramp = _init(model, :eq_min_upflexiramp)
eq_min_dwflexiramp = _init(model, :eq_min_dwflexiramp)
instance = model[:instance]
for r in instance.reserves
r.type == "flexiramp" || continue
for t in 1:instance.time
# Eq. (17) in Wang & Hobbs (2016)
eq_min_upflexiramp[r.name, t] = @constraint(
model,
sum(model[:upflexiramp][r.name, g.name, t] for g in r.units) +
model[:upflexiramp_shortfall][r.name, t] >= r.amount[t]
)
# Eq. (18) in Wang & Hobbs (2016)
eq_min_dwflexiramp[r.name, t] = @constraint(
model,
sum(model[:dwflexiramp][r.name, g.name, t] for g in r.units) +
model[:dwflexiramp_shortfall][r.name, t] >= r.amount[t]
)
# Account for flexiramp shortfall contribution to objective
if r.shortfall_penalty >= 0
add_to_expression!(
model[:obj],
r.shortfall_penalty,
(
model[:upflexiramp_shortfall][r.name, t] +
model[:dwflexiramp_shortfall][r.name, t]
),
)
end
end
end
return
end

@ -12,7 +12,8 @@ function _add_unit!(model::JuMP.Model, g::Unit, formulation::Formulation)
# Variables # Variables
_add_production_vars!(model, g, formulation.prod_vars) _add_production_vars!(model, g, formulation.prod_vars)
_add_reserve_vars!(model, g) _add_spinning_reserve_vars!(model, g)
_add_flexiramp_reserve_vars!(model, g)
_add_startup_shutdown_vars!(model, g) _add_startup_shutdown_vars!(model, g)
_add_status_vars!(model, g, formulation.status_vars) _add_status_vars!(model, g, formulation.status_vars)
@ -42,10 +43,11 @@ end
_is_initially_on(g::Unit)::Float64 = (g.initial_status > 0 ? 1.0 : 0.0) _is_initially_on(g::Unit)::Float64 = (g.initial_status > 0 ? 1.0 : 0.0)
function _add_reserve_vars!(model::JuMP.Model, g::Unit)::Nothing function _add_spinning_reserve_vars!(model::JuMP.Model, g::Unit)::Nothing
reserve = _init(model, :reserve) reserve = _init(model, :reserve)
reserve_shortfall = _init(model, :reserve_shortfall) reserve_shortfall = _init(model, :reserve_shortfall)
for r in g.reserves for r in g.reserves
r.type == "spinning" || continue
for t in 1:model[:instance].time for t in 1:model[:instance].time
reserve[r.name, g.name, t] = @variable(model, lower_bound = 0) reserve[r.name, g.name, t] = @variable(model, lower_bound = 0)
if (r.name, t) keys(reserve_shortfall) if (r.name, t) keys(reserve_shortfall)
@ -59,6 +61,32 @@ function _add_reserve_vars!(model::JuMP.Model, g::Unit)::Nothing
return return
end end
function _add_flexiramp_reserve_vars!(model::JuMP.Model, g::Unit)::Nothing
upflexiramp = _init(model, :upflexiramp)
upflexiramp_shortfall = _init(model, :upflexiramp_shortfall)
mfg = _init(model, :mfg)
dwflexiramp = _init(model, :dwflexiramp)
dwflexiramp_shortfall = _init(model, :dwflexiramp_shortfall)
for r in g.reserves
r.type == "flexiramp" || continue
for t in 1:model[:instance].time
# maximum feasible generation, \bar{g_{its}} in Wang & Hobbs (2016)
mfg[r.name, g.name, t] = @variable(model, lower_bound = 0)
upflexiramp[r.name, g.name, t] = @variable(model) # up-flexiramp, ur_{it} in Wang & Hobbs (2016)
dwflexiramp[r.name, g.name, t] = @variable(model) # down-flexiramp, dr_{it} in Wang & Hobbs (2016)
if (r.name, t) keys(upflexiramp_shortfall)
upflexiramp_shortfall[r.name, t] = @variable(model, lower_bound = 0)
dwflexiramp_shortfall[r.name, t] = @variable(model, lower_bound = 0)
if r.shortfall_penalty < 0
set_upper_bound(upflexiramp_shortfall[r.name, t], 0.0)
set_upper_bound(dwflexiramp_shortfall[r.name, t], 0.0)
end
end
end
end
return
end
function _add_startup_shutdown_vars!(model::JuMP.Model, g::Unit)::Nothing function _add_startup_shutdown_vars!(model::JuMP.Model, g::Unit)::Nothing
startup = _init(model, :startup) startup = _init(model, :startup)
for t in 1:model[:instance].time for t in 1:model[:instance].time
@ -176,16 +204,14 @@ function _add_min_uptime_downtime_eqs!(model::JuMP.Model, g::Unit)::Nothing
eq_min_uptime[g.name, 0] = @constraint( eq_min_uptime[g.name, 0] = @constraint(
model, model,
sum( sum(
switch_off[g.name, i] for switch_off[g.name, i] for i in 1:(g.min_uptime-g.initial_status) if i <= T
i in 1:(g.min_uptime-g.initial_status) if i <= T
) == 0 ) == 0
) )
else else
eq_min_downtime[g.name, 0] = @constraint( eq_min_downtime[g.name, 0] = @constraint(
model, model,
sum( sum(
switch_on[g.name, i] for switch_on[g.name, i] for i in 1:(g.min_downtime+g.initial_status) if i <= T
i in 1:(g.min_downtime+g.initial_status) if i <= T
) == 0 ) == 0
) )
end end
@ -213,10 +239,10 @@ end
function _total_reserves(model, g)::Vector function _total_reserves(model, g)::Vector
T = model[:instance].time T = model[:instance].time
reserve = [0.0 for _ in 1:T] reserve = [0.0 for _ in 1:T]
if !isempty(g.reserves) spinning_reserves = [r for r in g.reserves if r.type == "spinning"]
if !isempty(spinning_reserves)
reserve += [ reserve += [
sum(model[:reserve][r.name, g.name, t] for r in g.reserves) for sum(model[:reserve][r.name, g.name, t] for r in spinning_reserves) for t in 1:model[:instance].time
t in 1:model[:instance].time
] ]
end end
return reserve return reserve

@ -27,10 +27,11 @@ function fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
end end
end end
for r in instance.reserves for r in instance.reserves
r.type == "spinning" || continue
for g in r.units for g in r.units
for t in 1:T for t in 1:T
reserve_value = round( reserve_value = round(
solution["Reserve (MW)"][r.name][g.name][t], solution["Spinning reserve (MW)"][r.name][g.name][t],
digits = 5, digits = 5,
) )
JuMP.fix( JuMP.fix(

@ -3,13 +3,12 @@
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
function optimize!(model::JuMP.Model, method::XavQiuWanThi2019.Method)::Nothing function optimize!(model::JuMP.Model, method::XavQiuWanThi2019.Method)::Nothing
if !occursin("Gurobi", JuMP.solver_name(model))
method.two_phase_gap = false
end
function set_gap(gap) function set_gap(gap)
try JuMP.set_optimizer_attribute(model, "MIPGap", gap)
JuMP.set_optimizer_attribute(model, "MIPGap", gap) @info @sprintf("MIP gap tolerance set to %f", gap)
@info @sprintf("MIP gap tolerance set to %f", gap)
catch
@warn "Could not change MIP gap tolerance"
end
end end
initial_time = time() initial_time = time()
large_gap = false large_gap = false
@ -17,8 +16,6 @@ function optimize!(model::JuMP.Model, method::XavQiuWanThi2019.Method)::Nothing
if has_transmission && method.two_phase_gap if has_transmission && method.two_phase_gap
set_gap(1e-2) set_gap(1e-2)
large_gap = true large_gap = true
else
set_gap(method.gap_limit)
end end
while true while true
time_elapsed = time() - initial_time time_elapsed = time() - initial_time

@ -13,7 +13,7 @@ Lazy constraint solution method described in:
module XavQiuWanThi2019 module XavQiuWanThi2019
import ..SolutionMethod import ..SolutionMethod
""" """
struct Method mutable struct Method
time_limit::Float64 time_limit::Float64
gap_limit::Float64 gap_limit::Float64
two_phase_gap::Bool two_phase_gap::Bool
@ -27,7 +27,7 @@ Fields
- `time_limit`: - `time_limit`:
the time limit over the entire optimization procedure. the time limit over the entire optimization procedure.
- `gap_limit`: - `gap_limit`:
the desired relative optimality gap. the desired relative optimality gap. Only used when `two_phase_gap=true`.
- `two_phase_gap`: - `two_phase_gap`:
if true, solve the problem with large gap tolerance first, then reduce if true, solve the problem with large gap tolerance first, then reduce
the gap tolerance when no further violated constraints are found. the gap tolerance when no further violated constraints are found.
@ -39,7 +39,7 @@ Fields
formulation per time period. formulation per time period.
""" """
struct Method <: SolutionMethod mutable struct Method <: SolutionMethod
time_limit::Float64 time_limit::Float64
gap_limit::Float64 gap_limit::Float64
two_phase_gap::Bool two_phase_gap::Bool

@ -15,8 +15,7 @@ function solution(model::JuMP.Model)::OrderedDict
value(model[:is_on][g.name, t]) * g.min_power_cost[t] + sum( value(model[:is_on][g.name, t]) * g.min_power_cost[t] + sum(
Float64[ Float64[
value(model[:segprod][g.name, t, k]) * value(model[:segprod][g.name, t, k]) *
g.cost_segments[k].cost[t] for g.cost_segments[k].cost[t] for k in 1:length(g.cost_segments)
k in 1:length(g.cost_segments)
], ],
) for t in 1:T ) for t in 1:T
] ]
@ -25,8 +24,7 @@ function solution(model::JuMP.Model)::OrderedDict
return [ return [
value(model[:is_on][g.name, t]) * g.min_power[t] + sum( value(model[:is_on][g.name, t]) * g.min_power[t] + sum(
Float64[ Float64[
value(model[:segprod][g.name, t, k]) for value(model[:segprod][g.name, t, k]) for k in 1:length(g.cost_segments)
k in 1:length(g.cost_segments)
], ],
) for t in 1:T ) for t in 1:T
] ]
@ -60,19 +58,45 @@ function solution(model::JuMP.Model)::OrderedDict
sol["Price-sensitive loads (MW)"] = sol["Price-sensitive loads (MW)"] =
timeseries(model[:loads], instance.price_sensitive_loads) timeseries(model[:loads], instance.price_sensitive_loads)
end end
sol["Reserve (MW)"] = OrderedDict( sol["Spinning reserve (MW)"] = OrderedDict(
r.name => OrderedDict( r.name => OrderedDict(
g.name => [ g.name => [
value(model[:reserve][r.name, g.name, t]) for value(model[:reserve][r.name, g.name, t]) for t in 1:instance.time
t in 1:instance.time
] for g in r.units ] for g in r.units
) for r in instance.reserves ) for r in instance.reserves if r.type == "spinning"
) )
sol["Reserve shortfall (MW)"] = OrderedDict( sol["Spinning reserve shortfall (MW)"] = OrderedDict(
r.name => [ r.name => [
value(model[:reserve_shortfall][r.name, t]) for value(model[:reserve_shortfall][r.name, t]) for t in 1:instance.time
t in 1:instance.time ] for r in instance.reserves if r.type == "spinning"
] for r in instance.reserves )
sol["Up-flexiramp (MW)"] = OrderedDict(
r.name => OrderedDict(
g.name => [
value(model[:upflexiramp][r.name, g.name, t])
for t in 1:instance.time
] for g in r.units
) for r in instance.reserves if r.type == "flexiramp"
)
sol["Up-flexiramp shortfall (MW)"] = OrderedDict(
r.name => [
value(model[:upflexiramp_shortfall][r.name, t])
for t in 1:instance.time
] for r in instance.reserves if r.type == "flexiramp"
)
sol["Down-flexiramp (MW)"] = OrderedDict(
r.name => OrderedDict(
g.name => [
value(model[:dwflexiramp][r.name, g.name, t])
for t in 1:instance.time
] for g in r.units
) for r in instance.reserves if r.type == "flexiramp"
)
sol["Down-flexiramp shortfall (MW)"] = OrderedDict(
r.name => [
value(model[:upflexiramp_shortfall][r.name, t])
for t in 1:instance.time
] for r in instance.reserves if r.type == "flexiramp"
) )
return sol return sol
end end

@ -118,11 +118,12 @@ Base.@kwdef struct Randomization
end end
function _randomize_costs( function _randomize_costs(
rng,
instance::UnitCommitmentInstance, instance::UnitCommitmentInstance,
distribution, distribution,
)::Nothing )::Nothing
for unit in instance.units for unit in instance.units
α = rand(distribution) α = rand(rng, distribution)
unit.min_power_cost *= α unit.min_power_cost *= α
for k in unit.cost_segments for k in unit.cost_segments
k.cost *= α k.cost *= α
@ -135,10 +136,11 @@ function _randomize_costs(
end end
function _randomize_load_share( function _randomize_load_share(
rng,
instance::UnitCommitmentInstance, instance::UnitCommitmentInstance,
distribution, distribution,
)::Nothing )::Nothing
α = rand(distribution, length(instance.buses)) α = rand(rng, distribution, length(instance.buses))
for t in 1:instance.time for t in 1:instance.time
total = sum(bus.load[t] for bus in instance.buses) total = sum(bus.load[t] for bus in instance.buses)
den = sum( den = sum(
@ -153,6 +155,7 @@ function _randomize_load_share(
end end
function _randomize_load_profile( function _randomize_load_profile(
rng,
instance::UnitCommitmentInstance, instance::UnitCommitmentInstance,
params::Randomization, params::Randomization,
)::Nothing )::Nothing
@ -161,12 +164,13 @@ function _randomize_load_profile(
for t in 2:instance.time for t in 2:instance.time
idx = (t - 1) % length(params.load_profile_mu) + 1 idx = (t - 1) % length(params.load_profile_mu) + 1
gamma = rand( gamma = rand(
rng,
Normal(params.load_profile_mu[idx], params.load_profile_sigma[idx]), Normal(params.load_profile_mu[idx], params.load_profile_sigma[idx]),
) )
push!(system_load, system_load[t-1] * gamma) push!(system_load, system_load[t-1] * gamma)
end end
capacity = sum(maximum(u.max_power) for u in instance.units) capacity = sum(maximum(u.max_power) for u in instance.units)
peak_load = rand(params.peak_load) * capacity peak_load = rand(rng, params.peak_load) * capacity
system_load = system_load ./ maximum(system_load) .* peak_load system_load = system_load ./ maximum(system_load) .* peak_load
# Scale bus loads to match the new system load # Scale bus loads to match the new system load
@ -186,22 +190,24 @@ end
function randomize!( function randomize!(
instance::UnitCommitment.UnitCommitmentInstance, instance::UnitCommitment.UnitCommitmentInstance,
method::XavQiuAhm2021.Randomization, method::XavQiuAhm2021.Randomization,
rng = MersenneTwister(),
)::Nothing )::Nothing
Randomize costs and loads based on the method described in XavQiuAhm2021. Randomize costs and loads based on the method described in XavQiuAhm2021.
""" """
function randomize!( function randomize!(
instance::UnitCommitment.UnitCommitmentInstance, instance::UnitCommitment.UnitCommitmentInstance,
method::XavQiuAhm2021.Randomization, method::XavQiuAhm2021.Randomization;
rng = MersenneTwister(),
)::Nothing )::Nothing
if method.randomize_costs if method.randomize_costs
XavQiuAhm2021._randomize_costs(instance, method.cost) XavQiuAhm2021._randomize_costs(rng, instance, method.cost)
end end
if method.randomize_load_share if method.randomize_load_share
XavQiuAhm2021._randomize_load_share(instance, method.load_share) XavQiuAhm2021._randomize_load_share(rng, instance, method.load_share)
end end
if method.randomize_load_profile if method.randomize_load_profile
XavQiuAhm2021._randomize_load_profile(instance, method) XavQiuAhm2021._randomize_load_profile(rng, instance, method)
end end
return return
end end

@ -5,20 +5,11 @@
import Logging: min_enabled_level, shouldlog, handle_message import Logging: min_enabled_level, shouldlog, handle_message
using Base.CoreLogging, Logging, Printf using Base.CoreLogging, Logging, Printf
struct TimeLogger <: AbstractLogger Base.@kwdef struct TimeLogger <: AbstractLogger
initial_time::Float64 initial_time::Float64
file::Union{Nothing,IOStream} file::Union{Nothing,IOStream} = nothing
screen_log_level::Any screen_log_level::Any = CoreLogging.Info
io_log_level::Any io_log_level::Any = CoreLogging.Info
end
function TimeLogger(;
initial_time::Float64,
file::Union{Nothing,IOStream} = nothing,
screen_log_level = CoreLogging.Info,
io_log_level = CoreLogging.Info,
)::TimeLogger
return TimeLogger(initial_time, file, screen_log_level, io_log_level)
end end
min_enabled_level(logger::TimeLogger) = logger.io_log_level min_enabled_level(logger::TimeLogger) = logger.io_log_level
@ -61,7 +52,9 @@ function handle_message(
end end
end end
function _setup_logger() function _setup_logger(; level = CoreLogging.Info)
initial_time = time() initial_time = time()
return global_logger(TimeLogger(initial_time = initial_time)) return global_logger(
TimeLogger(initial_time = initial_time, screen_log_level = level),
)
end end

@ -46,10 +46,11 @@ function _validate_units(instance::UnitCommitmentInstance, solution; tol = 0.01)
for unit in instance.units for unit in instance.units
production = solution["Production (MW)"][unit.name] production = solution["Production (MW)"][unit.name]
reserve = [0.0 for _ in 1:instance.time] reserve = [0.0 for _ in 1:instance.time]
if !isempty(unit.reserves) spinning_reserves = [r for r in unit.reserves if r.type == "spinning"]
if !isempty(spinning_reserves)
reserve += sum( reserve += sum(
solution["Reserve (MW)"][r.name][unit.name] for solution["Spinning reserve (MW)"][r.name][unit.name]
r in unit.reserves for r in spinning_reserves
) )
end end
actual_production_cost = solution["Production cost (\$)"][unit.name] actual_production_cost = solution["Production cost (\$)"][unit.name]
@ -106,14 +107,16 @@ function _validate_units(instance::UnitCommitmentInstance, solution; tol = 0.01)
# Verify reserve eligibility # Verify reserve eligibility
for r in instance.reserves for r in instance.reserves
if unit r.units && if r.type == "spinning"
(unit in keys(solution["Reserve (MW)"][r.name])) if unit r.units &&
@error @sprintf( (unit in keys(solution["Spinning reserve (MW)"][r.name]))
"Unit %s is not eligible to provide reserve %s", @error @sprintf(
unit.name, "Unit %s is not eligible to provide reserve %s",
r.name, unit.name,
) r.name,
err_count += 1 )
err_count += 1
end
end end
end end
@ -305,16 +308,14 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
ps_load = 0 ps_load = 0
if length(instance.price_sensitive_loads) > 0 if length(instance.price_sensitive_loads) > 0
ps_load = sum( ps_load = sum(
solution["Price-sensitive loads (MW)"][ps.name][t] for solution["Price-sensitive loads (MW)"][ps.name][t] for ps in instance.price_sensitive_loads
ps in instance.price_sensitive_loads
) )
end end
production = production =
sum(solution["Production (MW)"][g.name][t] for g in instance.units) sum(solution["Production (MW)"][g.name][t] for g in instance.units)
if "Load curtail (MW)" in keys(solution) if "Load curtail (MW)" in keys(solution)
load_curtail = sum( load_curtail = sum(
solution["Load curtail (MW)"][b.name][t] for solution["Load curtail (MW)"][b.name][t] for b in instance.buses
b in instance.buses
) )
end end
balance = fixed_load - load_curtail - production + ps_load balance = fixed_load - load_curtail - production + ps_load
@ -334,22 +335,58 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
# Verify reserves # Verify reserves
for r in instance.reserves for r in instance.reserves
provided = sum( if r.type == "spinning"
solution["Reserve (MW)"][r.name][g.name][t] for g in r.units provided = sum(
) solution["Spinning reserve (MW)"][r.name][g.name][t] for g in r.units
shortfall = solution["Reserve shortfall (MW)"][r.name][t] )
required = r.amount[t] shortfall = solution["Spinning reserve shortfall (MW)"][r.name][t]
required = r.amount[t]
if provided + shortfall < required - tol if provided + shortfall < required - tol
@error @sprintf( @error @sprintf(
"Insufficient reserve %s at time %d (%.2f + %.2f < %.2f)", "Insufficient reserve %s at time %d (%.2f + %.2f < %.2f)",
r.name, r.name,
t, t,
provided, provided,
shortfall, shortfall,
required, required,
)
end
elseif r.type == "flexiramp"
upflexiramp = sum(
solution["Up-flexiramp (MW)"][r.name][g.name][t] for g in r.units
) )
err_count += 1 upflexiramp_shortfall =
solution["Up-flexiramp shortfall (MW)"][r.name][t]
if upflexiramp + upflexiramp_shortfall < r.amount[t] - tol
@error @sprintf(
"Insufficient up-flexiramp at time %d (%.2f + %.2f < %.2f)",
t,
upflexiramp,
upflexiramp_shortfall,
r.amount[t],
)
err_count += 1
end
dwflexiramp = sum(
solution["Down-flexiramp (MW)"][r.name][g.name][t] for g in r.units
)
dwflexiramp_shortfall = solution["Down-flexiramp shortfall (MW)"][r.name][t]
if dwflexiramp + dwflexiramp_shortfall < r.amount[t] - tol
@error @sprintf(
"Insufficient down-flexiramp at time %d (%.2f + %.2f < %.2f)",
t,
dwflexiramp,
dwflexiramp_shortfall,
r.amount[t],
)
err_count += 1
end
else
error("Unknown reserve type: $(r.type)")
end end
end end
end end

@ -3,7 +3,6 @@ Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76"
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
GZip = "92fee26a-97fe-5a0c-ad85-20a5f3185b63" GZip = "92fee26a-97fe-5a0c-ad85-20a5f3185b63"
Gurobi = "2e9cd046-0924-5485-92f1-d5272153d98b"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JuMP = "4076af6c-e467-56ae-b986-b466b2749572" JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
@ -20,7 +19,7 @@ DataStructures = "0.18"
Distributions = "0.25" Distributions = "0.25"
GZip = "0.5" GZip = "0.5"
JSON = "0.21" JSON = "0.21"
JuMP = "0.21" JuMP = "1"
MathOptInterface = "0.9" MathOptInterface = "1"
PackageCompiler = "1" PackageCompiler = "1"
julia = "1" julia = "1"

@ -15,56 +15,36 @@ import UnitCommitment:
KnuOstWat2018, KnuOstWat2018,
MorLatRam2013, MorLatRam2013,
PanGua2016, PanGua2016,
XavQiuWanThi2019 XavQiuWanThi2019,
WanHob2016
if ENABLE_LARGE_TESTS function _test(
using Gurobi formulation::Formulation;
end instances=["test/case14"],
dump::Bool = false,
function _small_test(formulation::Formulation; dump::Bool = false)::Nothing )::Nothing
instance = UnitCommitment.read_benchmark("test/case14") for instance_name in instances
model = UnitCommitment.build_model( instance = UnitCommitment.read_benchmark(instance_name)
instance = instance, model = UnitCommitment.build_model(
formulation = formulation, instance = instance,
optimizer = Cbc.Optimizer, formulation = formulation,
variable_names = true, optimizer = Cbc.Optimizer,
) variable_names = true,
UnitCommitment.optimize!(model) )
solution = UnitCommitment.solution(model) set_silent(model)
if dump UnitCommitment.optimize!(model)
open("/tmp/ucjl.json", "w") do f solution = UnitCommitment.solution(model)
return write(f, JSON.json(solution, 2)) if dump
open("/tmp/ucjl.json", "w") do f
return write(f, JSON.json(solution, 2))
end
write_to_file(model, "/tmp/ucjl.lp")
end end
write_to_file(model, "/tmp/ucjl.lp") @test UnitCommitment.validate(instance, solution)
end end
@test UnitCommitment.validate(instance, solution)
return
end
function _large_test(formulation::Formulation)::Nothing
instance =
UnitCommitment.read_benchmark("pglib-uc/ca/Scenario400_reserves_1")
model = UnitCommitment.build_model(
instance = instance,
formulation = formulation,
optimizer = Gurobi.Optimizer,
)
UnitCommitment.optimize!(
model,
XavQiuWanThi2019.Method(two_phase_gap = false, gap_limit = 0.1),
)
solution = UnitCommitment.solution(model)
@test UnitCommitment.validate(instance, solution)
return return
end end
function _test(formulation::Formulation; dump::Bool = false)::Nothing
_small_test(formulation; dump)
if ENABLE_LARGE_TESTS
_large_test(formulation)
end
end
@testset "formulations" begin @testset "formulations" begin
@testset "default" begin @testset "default" begin
_test(Formulation()) _test(Formulation())
@ -95,4 +75,10 @@ end
@testset "KnuOstWat2018" begin @testset "KnuOstWat2018" begin
_test(Formulation(pwl_costs = KnuOstWat2018.PwlCosts())) _test(Formulation(pwl_costs = KnuOstWat2018.PwlCosts()))
end end
@testset "WanHob2016" begin
_test(
Formulation(ramping = WanHob2016.Ramping()),
instances = ["test/case14-flex"],
)
end
end end

@ -6,9 +6,7 @@ using Test
using UnitCommitment using UnitCommitment
push!(Base.LOAD_PATH, @__DIR__) push!(Base.LOAD_PATH, @__DIR__)
UnitCommitment._setup_logger() UnitCommitment._setup_logger(level = Base.CoreLogging.Error)
const ENABLE_LARGE_TESTS = ("UCJL_LARGE_TESTS" in keys(ENV))
@testset "UnitCommitment" begin @testset "UnitCommitment" begin
include("usage.jl") include("usage.jl")

@ -6,6 +6,7 @@ import Random
import UnitCommitment: XavQiuAhm2021 import UnitCommitment: XavQiuAhm2021
using Distributions using Distributions
using Random
using UnitCommitment, Cbc, JuMP using UnitCommitment, Cbc, JuMP
get_instance() = UnitCommitment.read_benchmark("matpower/case118/2017-02-01") get_instance() = UnitCommitment.read_benchmark("matpower/case118/2017-02-01")
@ -27,10 +28,10 @@ test_approx(x, y) = @test isapprox(x, y, atol = 1e-3)
prev_system_load = system_load(instance) prev_system_load = system_load(instance)
test_approx(bus.load[1] / prev_system_load[1], 0.012) test_approx(bus.load[1] / prev_system_load[1], 0.012)
Random.seed!(42)
randomize!( randomize!(
instance, instance,
XavQiuAhm2021.Randomization(randomize_load_profile = false), XavQiuAhm2021.Randomization(randomize_load_profile = false),
rng = MersenneTwister(42),
) )
# Check randomized costs # Check randomized costs
@ -53,8 +54,11 @@ test_approx(x, y) = @test isapprox(x, y, atol = 1e-3)
@test round.(system_load(instance), digits = 1)[1:8] @test round.(system_load(instance), digits = 1)[1:8]
[3059.5, 2983.2, 2937.5, 2953.9, 3073.1, 3356.4, 4068.5, 4018.8] [3059.5, 2983.2, 2937.5, 2953.9, 3073.1, 3356.4, 4068.5, 4018.8]
Random.seed!(42) randomize!(
randomize!(instance, XavQiuAhm2021.Randomization()) instance,
XavQiuAhm2021.Randomization(),
rng = MersenneTwister(42),
)
# Check randomized load profile # Check randomized load profile
@test round.(system_load(instance), digits = 1)[1:8] @test round.(system_load(instance), digits = 1)[1:8]

@ -34,7 +34,6 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test length(ps.demand) == 2 @test length(ps.demand) == 2
@test length(ps.revenue) == 2 @test length(ps.revenue) == 2
end end
# Should be able to build model without errors # Should be able to build model without errors
optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0) optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0)
model = UnitCommitment.build_model( model = UnitCommitment.build_model(

Loading…
Cancel
Save