Reorganize files; document some methods

bugfix/formulations
Alinson S. Xavier 4 years ago
parent e594a68492
commit 4e8426beba

@ -5,11 +5,11 @@
JULIA := julia --color=yes --project=@.
VERSION := 0.2
build/sysimage.so: src/sysimage.jl Project.toml Manifest.toml
build/sysimage.so: src/utils/sysimage.jl Project.toml Manifest.toml
mkdir -p build
mkdir -p benchmark/results/test
cd benchmark; $(JULIA) --trace-compile=../build/precompile.jl run.jl test/case14.1.sol.json
$(JULIA) src/sysimage.jl
$(JULIA) src/utils/sysimage.jl
clean:
rm -rf build/*

@ -3,12 +3,30 @@
# Released under the modified BSD license. See COPYING.md for more details.
module UnitCommitment
include("log.jl")
include("instance.jl")
include("screening.jl")
include("model.jl")
include("sensitivity.jl")
include("validate.jl")
include("convert.jl")
include("initcond.jl")
include("instance/structs.jl")
include("transmission/structs.jl")
include("solution/structs.jl")
include("solution/methods/XaQiWaTh19/structs.jl")
include("import/egret.jl")
include("instance/read.jl")
include("model/build.jl")
include("model/jumpext.jl")
include("solution/fix.jl")
include("solution/methods/XaQiWaTh19/enforce.jl")
include("solution/methods/XaQiWaTh19/filter.jl")
include("solution/methods/XaQiWaTh19/find.jl")
include("solution/methods/XaQiWaTh19/optimize.jl")
include("solution/optimize.jl")
include("solution/solution.jl")
include("solution/warmstart.jl")
include("solution/write.jl")
include("transforms/initcond.jl")
include("transforms/slice.jl")
include("transmission/sensitivity.jl")
include("utils/log.jl")
include("validation/repair.jl")
include("validation/validate.jl")
end

@ -4,16 +4,15 @@
using DataStructures, JSON, GZip
function _read_json(path::String)::OrderedDict
if endswith(path, ".gz")
file = GZip.gzopen(path)
else
file = open(path)
end
return JSON.parse(file, dicttype = () -> DefaultOrderedDict(nothing))
end
"""
read_egret_solution(path::String)::OrderedDict
function _read_egret_solution(path::String)::OrderedDict
Read a JSON solution file produced by EGRET and transforms it into a
dictionary having the same structure as the one produced by
UnitCommitment.solution(model).
"""
function read_egret_solution(path::String)::OrderedDict
egret = _read_json(path)
T = length(egret["system"]["time_keys"])

@ -8,104 +8,35 @@ using DataStructures
using GZip
import Base: getindex, time
mutable struct Bus
name::String
offset::Int
load::Vector{Float64}
units::Vector
price_sensitive_loads::Vector
end
mutable struct CostSegment
mw::Vector{Float64}
cost::Vector{Float64}
end
mutable struct StartupCategory
delay::Int
cost::Float64
end
mutable struct Unit
name::String
bus::Bus
max_power::Vector{Float64}
min_power::Vector{Float64}
must_run::Vector{Bool}
min_power_cost::Vector{Float64}
cost_segments::Vector{CostSegment}
min_uptime::Int
min_downtime::Int
ramp_up_limit::Float64
ramp_down_limit::Float64
startup_limit::Float64
shutdown_limit::Float64
initial_status::Union{Int,Nothing}
initial_power::Union{Float64,Nothing}
provides_spinning_reserves::Vector{Bool}
startup_categories::Vector{StartupCategory}
end
mutable struct TransmissionLine
name::String
offset::Int
source::Bus
target::Bus
reactance::Float64
susceptance::Float64
normal_flow_limit::Vector{Float64}
emergency_flow_limit::Vector{Float64}
flow_limit_penalty::Vector{Float64}
end
"""
read_benchmark(name::AbstractString)::UnitCommitmentInstance
mutable struct Reserves
spinning::Vector{Float64}
end
Read one of the benchmark unit commitment instances included in the package.
See "Instances" section of the documentation for the entire list of benchmark
instances available.
mutable struct Contingency
name::String
lines::Vector{TransmissionLine}
units::Vector{Unit}
end
Example
-------
mutable struct PriceSensitiveLoad
name::String
bus::Bus
demand::Vector{Float64}
revenue::Vector{Float64}
import UnitCommitment
instance = UnitCommitment.read_benchmark("matpower/case3375wp/2017-02-01")
"""
function read_benchmark(name::AbstractString)::UnitCommitmentInstance
basedir = dirname(@__FILE__)
return UnitCommitment.read("$basedir/../../instances/$name.json.gz")
end
mutable struct UnitCommitmentInstance
time::Int
power_balance_penalty::Vector{Float64}
units::Vector{Unit}
buses::Vector{Bus}
lines::Vector{TransmissionLine}
reserves::Reserves
contingencies::Vector{Contingency}
price_sensitive_loads::Vector{PriceSensitiveLoad}
end
"""
read(path::AbstractString)::UnitCommitmentInstance
function Base.show(io::IO, instance::UnitCommitmentInstance)
print(io, "UnitCommitmentInstance(")
print(io, "$(length(instance.units)) units, ")
print(io, "$(length(instance.buses)) buses, ")
print(io, "$(length(instance.lines)) lines, ")
print(io, "$(length(instance.contingencies)) contingencies, ")
print(
io,
"$(length(instance.price_sensitive_loads)) price sensitive loads, ",
)
print(io, "$(instance.time) time steps")
print(io, ")")
return
end
Read a unit commitment instance from a file. The file may be gzipped.
function read_benchmark(name::AbstractString)::UnitCommitmentInstance
basedir = dirname(@__FILE__)
return UnitCommitment.read("$basedir/../instances/$name.json.gz")
end
Example
-------
import UnitCommitment
instance = UnitCommitment.read("/path/to/input.json.gz")
"""
function read(path::AbstractString)::UnitCommitmentInstance
if endswith(path, ".gz")
return _read(gzopen(path))
@ -120,6 +51,15 @@ function _read(file::IO)::UnitCommitmentInstance
)
end
function _read_json(path::String)::OrderedDict
if endswith(path, ".gz")
file = GZip.gzopen(path)
else
file = open(path)
end
return JSON.parse(file, dicttype = () -> DefaultOrderedDict(nothing))
end
function _from_json(json; repair = true)
units = Unit[]
buses = Bus[]
@ -336,54 +276,3 @@ function _from_json(json; repair = true)
end
return instance
end
"""
slice(instance, range)
Creates a new instance, with only a subset of the time periods.
This function does not modify the provided instance. The initial
conditions are also not modified.
Example
-------
# Build a 2-hour UC instance
instance = UnitCommitment.read_benchmark("test/case14")
modified = UnitCommitment.slice(instance, 1:2)
"""
function slice(
instance::UnitCommitmentInstance,
range::UnitRange{Int},
)::UnitCommitmentInstance
modified = deepcopy(instance)
modified.time = length(range)
modified.power_balance_penalty = modified.power_balance_penalty[range]
modified.reserves.spinning = modified.reserves.spinning[range]
for u in modified.units
u.max_power = u.max_power[range]
u.min_power = u.min_power[range]
u.must_run = u.must_run[range]
u.min_power_cost = u.min_power_cost[range]
u.provides_spinning_reserves = u.provides_spinning_reserves[range]
for s in u.cost_segments
s.mw = s.mw[range]
s.cost = s.cost[range]
end
end
for b in modified.buses
b.load = b.load[range]
end
for l in modified.lines
l.normal_flow_limit = l.normal_flow_limit[range]
l.emergency_flow_limit = l.emergency_flow_limit[range]
l.flow_limit_penalty = l.flow_limit_penalty[range]
end
for ps in modified.price_sensitive_loads
ps.demand = ps.demand[range]
ps.revenue = ps.revenue[range]
end
return modified
end
export UnitCommitmentInstance

@ -0,0 +1,98 @@
# UnitCommitment.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.
mutable struct Bus
name::String
offset::Int
load::Vector{Float64}
units::Vector
price_sensitive_loads::Vector
end
mutable struct CostSegment
mw::Vector{Float64}
cost::Vector{Float64}
end
mutable struct StartupCategory
delay::Int
cost::Float64
end
mutable struct Unit
name::String
bus::Bus
max_power::Vector{Float64}
min_power::Vector{Float64}
must_run::Vector{Bool}
min_power_cost::Vector{Float64}
cost_segments::Vector{CostSegment}
min_uptime::Int
min_downtime::Int
ramp_up_limit::Float64
ramp_down_limit::Float64
startup_limit::Float64
shutdown_limit::Float64
initial_status::Union{Int,Nothing}
initial_power::Union{Float64,Nothing}
provides_spinning_reserves::Vector{Bool}
startup_categories::Vector{StartupCategory}
end
mutable struct TransmissionLine
name::String
offset::Int
source::Bus
target::Bus
reactance::Float64
susceptance::Float64
normal_flow_limit::Vector{Float64}
emergency_flow_limit::Vector{Float64}
flow_limit_penalty::Vector{Float64}
end
mutable struct Reserves
spinning::Vector{Float64}
end
mutable struct Contingency
name::String
lines::Vector{TransmissionLine}
units::Vector{Unit}
end
mutable struct PriceSensitiveLoad
name::String
bus::Bus
demand::Vector{Float64}
revenue::Vector{Float64}
end
mutable struct UnitCommitmentInstance
time::Int
power_balance_penalty::Vector{Float64}
units::Vector{Unit}
buses::Vector{Bus}
lines::Vector{TransmissionLine}
reserves::Reserves
contingencies::Vector{Contingency}
price_sensitive_loads::Vector{PriceSensitiveLoad}
end
function Base.show(io::IO, instance::UnitCommitmentInstance)
print(io, "UnitCommitmentInstance(")
print(io, "$(length(instance.units)) units, ")
print(io, "$(length(instance.buses)) buses, ")
print(io, "$(length(instance.lines)) lines, ")
print(io, "$(length(instance.contingencies)) contingencies, ")
print(
io,
"$(length(instance.price_sensitive_loads)) price sensitive loads, ",
)
print(io, "$(instance.time) time steps")
print(io, ")")
return
end
export UnitCommitmentInstance

@ -5,23 +5,9 @@
using JuMP, MathOptInterface, DataStructures
import JuMP: value, fix, set_name
# Extend some JuMP functions so that decision variables can be safely replaced by
# (constant) floating point numbers.
function value(x::Float64)
return x
end
function fix(x::Float64, v::Float64; force)
return abs(x - v) < 1e-6 || error("Value mismatch: $x != $v")
end
function set_name(x::Float64, n::String)
# nop
end
"""
function build_model(;
filename::Union{String,Nothing} = nothing,
instance::Union{UnitCommitmentInstance,Nothing} = nothing,
instance::UnitCommitmentInstance,
isf::Union{Matrix{Float64},Nothing} = nothing,
lodf::Union{Matrix{Float64},Nothing} = nothing,
isf_cutoff::Float64 = 0.005,
@ -29,18 +15,53 @@ function build_model(;
optimizer = nothing,
variable_names::Bool = false,
)::JuMP.Model
if (filename === nothing) && (instance === nothing)
error("Either filename or instance must be specified")
end
if filename !== nothing
@info "Reading: $(filename)"
time_read = @elapsed begin
instance = UnitCommitment.read(filename)
end
@info @sprintf("Read problem in %.2f seconds", time_read)
end
Build the JuMP model corresponding to the given unit commitment instance.
Arguments
=========
- `instance::UnitCommitmentInstance`:
the instance.
- `isf::Union{Matrix{Float64},Nothing} = nothing`:
the injection shift factors matrix. If not provided, it will be computed.
- `lodf::Union{Matrix{Float64},Nothing} = nothing`:
the line outage distribution factors matrix. If not provided, it will be
computed.
- `isf_cutoff::Float64 = 0.005`:
the cutoff that should be applied to the ISF matrix. Entries with magnitude
smaller than this value will be set to zero.
- `lodf_cutoff::Float64 = 0.001`:
the cutoff that should be applied to the LODF matrix. Entries with magnitude
smaller than this value will be set to zero.
- `optimizer = nothing`:
the optimizer factory that should be attached to this model (e.g. Cbc.Optimizer).
If not provided, no optimizer will be attached.
- `variable_names::Bool = false`:
If true, set variable and constraint names. Important if the model is going
to be exported to an MPS file. For large models, this can take significant
time, so it's disabled by default.
Example
=======
```jldoctest
julia> import Cbc, UnitCommitment
julia> instance = UnitCommitment.read_benchmark("matpower/case118/2017-02-01")
julia> model = UnitCommitment.build_model(
instance=instance,
optimizer=Cbc.Optimizer,
variable_names=true,
)
```
"""
function build_model(;
instance::UnitCommitmentInstance,
isf::Union{Matrix{Float64},Nothing} = nothing,
lodf::Union{Matrix{Float64},Nothing} = nothing,
isf_cutoff::Float64 = 0.005,
lodf_cutoff::Float64 = 0.001,
optimizer = nothing,
variable_names::Bool = false,
)::JuMP.Model
if length(instance.buses) == 1
isf = zeros(0, 0)
lodf = zeros(0, 0)
@ -473,71 +494,6 @@ function _build_reserve_eqs!(model::JuMP.Model)
end
end
function _enforce_transmission(;
model::JuMP.Model,
violation::Violation,
isf::Matrix{Float64},
lodf::Matrix{Float64},
)::Nothing
instance = model[:instance]
limit::Float64 = 0.0
overflow = model[:overflow]
net_injection = model[:net_injection]
if violation.outage_line === nothing
limit = violation.monitored_line.normal_flow_limit[violation.time]
@info @sprintf(
" %8.3f MW overflow in %-5s time %3d (pre-contingency)",
violation.amount,
violation.monitored_line.name,
violation.time,
)
else
limit = violation.monitored_line.emergency_flow_limit[violation.time]
@info @sprintf(
" %8.3f MW overflow in %-5s time %3d (outage: line %s)",
violation.amount,
violation.monitored_line.name,
violation.time,
violation.outage_line.name,
)
end
fm = violation.monitored_line.name
t = violation.time
flow = @variable(model, base_name = "flow[$fm,$t]")
v = overflow[violation.monitored_line.name, violation.time]
@constraint(model, flow <= limit + v)
@constraint(model, -flow <= limit + v)
if violation.outage_line === nothing
@constraint(
model,
flow == sum(
net_injection[b.name, violation.time] *
isf[violation.monitored_line.offset, b.offset] for
b in instance.buses if b.offset > 0
)
)
else
@constraint(
model,
flow == sum(
net_injection[b.name, violation.time] * (
isf[violation.monitored_line.offset, b.offset] + (
lodf[
violation.monitored_line.offset,
violation.outage_line.offset,
] * isf[violation.outage_line.offset, b.offset]
)
) for b in instance.buses if b.offset > 0
)
)
end
return nothing
end
function _set_names!(model::JuMP.Model)
@info "Setting variable and constraint names..."
time_varnames = @elapsed begin
@ -558,230 +514,3 @@ function _set_names!(dict::Dict)
end
end
end
function solution(model::JuMP.Model)
instance, T = model[:instance], model[:instance].time
function timeseries(vars, collection)
return OrderedDict(
b.name => [round(value(vars[b.name, t]), digits = 5) for t in 1:T]
for b in collection
)
end
function production_cost(g)
return [
value(model[:is_on][g.name, t]) * g.min_power_cost[t] + sum(
Float64[
value(model[:segprod][g.name, t, k]) *
g.cost_segments[k].cost[t] for
k in 1:length(g.cost_segments)
],
) for t in 1:T
]
end
function production(g)
return [
value(model[:is_on][g.name, t]) * g.min_power[t] + sum(
Float64[
value(model[:segprod][g.name, t, k]) for
k in 1:length(g.cost_segments)
],
) for t in 1:T
]
end
function startup_cost(g)
S = length(g.startup_categories)
return [
sum(
g.startup_categories[s].cost *
value(model[:startup][g.name, t, s]) for s in 1:S
) for t in 1:T
]
end
sol = OrderedDict()
sol["Production (MW)"] =
OrderedDict(g.name => production(g) for g in instance.units)
sol["Production cost (\$)"] =
OrderedDict(g.name => production_cost(g) for g in instance.units)
sol["Startup cost (\$)"] =
OrderedDict(g.name => startup_cost(g) for g in instance.units)
sol["Is on"] = timeseries(model[:is_on], instance.units)
sol["Switch on"] = timeseries(model[:switch_on], instance.units)
sol["Switch off"] = timeseries(model[:switch_off], instance.units)
sol["Reserve (MW)"] = timeseries(model[:reserve], instance.units)
sol["Net injection (MW)"] =
timeseries(model[:net_injection], instance.buses)
sol["Load curtail (MW)"] = timeseries(model[:curtail], instance.buses)
if !isempty(instance.lines)
sol["Line overflow (MW)"] = timeseries(model[:overflow], instance.lines)
end
if !isempty(instance.price_sensitive_loads)
sol["Price-sensitive loads (MW)"] =
timeseries(model[:loads], instance.price_sensitive_loads)
end
return sol
end
function write(filename::AbstractString, solution::AbstractDict)::Nothing
open(filename, "w") do file
return JSON.print(file, solution, 2)
end
return
end
function fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
is_on = model[:is_on]
prod_above = model[:prod_above]
reserve = model[:reserve]
for g in instance.units
for t in 1:T
is_on_value = round(solution["Is on"][g.name][t])
production_value =
round(solution["Production (MW)"][g.name][t], digits = 5)
reserve_value =
round(solution["Reserve (MW)"][g.name][t], digits = 5)
JuMP.fix(is_on[g.name, t], is_on_value, force = true)
JuMP.fix(
prod_above[g.name, t],
production_value - is_on_value * g.min_power[t],
force = true,
)
JuMP.fix(reserve[g.name, t], reserve_value, force = true)
end
end
return
end
function set_warm_start!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
is_on = model[:is_on]
prod_above = model[:prod_above]
reserve = model[:reserve]
for g in instance.units
for t in 1:T
JuMP.set_start_value(is_on[g.name, t], solution["Is on"][g.name][t])
JuMP.set_start_value(
switch_on[g.name, t],
solution["Switch on"][g.name][t],
)
JuMP.set_start_value(
switch_off[g.name, t],
solution["Switch off"][g.name][t],
)
end
end
return
end
function optimize!(
model::JuMP.Model;
time_limit = 3600,
gap_limit = 1e-4,
two_phase_gap = true,
)::Nothing
function set_gap(gap)
try
JuMP.set_optimizer_attribute(model, "MIPGap", gap)
@info @sprintf("MIP gap tolerance set to %f", gap)
catch
@warn "Could not change MIP gap tolerance"
end
end
instance = model[:instance]
initial_time = time()
large_gap = false
has_transmission = (length(model[:isf]) > 0)
if has_transmission && two_phase_gap
set_gap(1e-2)
large_gap = true
else
set_gap(gap_limit)
end
while true
time_elapsed = time() - initial_time
time_remaining = time_limit - time_elapsed
if time_remaining < 0
@info "Time limit exceeded"
break
end
@info @sprintf(
"Setting MILP time limit to %.2f seconds",
time_remaining
)
JuMP.set_time_limit_sec(model, time_remaining)
@info "Solving MILP..."
JuMP.optimize!(model)
has_transmission || break
violations = _find_violations(model)
if isempty(violations)
@info "No violations found"
if large_gap
large_gap = false
set_gap(gap_limit)
else
break
end
else
_enforce_transmission(model, violations)
end
end
return
end
function _find_violations(model::JuMP.Model)
instance = model[:instance]
net_injection = model[:net_injection]
overflow = model[:overflow]
length(instance.buses) > 1 || return []
violations = []
@info "Verifying transmission limits..."
time_screening = @elapsed begin
non_slack_buses = [b for b in instance.buses if b.offset > 0]
net_injection_values = [
value(net_injection[b.name, t]) for b in non_slack_buses,
t in 1:instance.time
]
overflow_values = [
value(overflow[lm.name, t]) for lm in instance.lines,
t in 1:instance.time
]
violations = UnitCommitment._find_violations(
instance = instance,
net_injections = net_injection_values,
overflow = overflow_values,
isf = model[:isf],
lodf = model[:lodf],
)
end
@info @sprintf(
"Verified transmission limits in %.2f seconds",
time_screening
)
return violations
end
function _enforce_transmission(
model::JuMP.Model,
violations::Vector{Violation},
)::Nothing
for v in violations
_enforce_transmission(
model = model,
violation = v,
isf = model[:isf],
lodf = model[:lodf],
)
end
return
end
export build_model

@ -0,0 +1,20 @@
# UnitCommitment.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.
# This file extends some JuMP functions so that decision variables can be safely
# replaced by (constant) floating point numbers.
import JuMP: value, fix, set_name
function value(x::Float64)
return x
end
function fix(x::Float64, v::Float64; force)
return abs(x - v) < 1e-6 || error("Value mismatch: $x != $v")
end
function set_name(x::Float64, n::String)
# nop
end

@ -0,0 +1,33 @@
# UnitCommitment.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.
"""
fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
Fix the value of all binary variables to the ones specified by the given
solution. Useful for computing LMPs.
"""
function fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
is_on = model[:is_on]
prod_above = model[:prod_above]
reserve = model[:reserve]
for g in instance.units
for t in 1:T
is_on_value = round(solution["Is on"][g.name][t])
production_value =
round(solution["Production (MW)"][g.name][t], digits = 5)
reserve_value =
round(solution["Reserve (MW)"][g.name][t], digits = 5)
JuMP.fix(is_on[g.name, t], is_on_value, force = true)
JuMP.fix(
prod_above[g.name, t],
production_value - is_on_value * g.min_power[t],
force = true,
)
JuMP.fix(reserve[g.name, t], reserve_value, force = true)
end
end
return
end

@ -0,0 +1,83 @@
# UnitCommitment.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 _enforce_transmission(
model::JuMP.Model,
violations::Vector{_Violation},
)::Nothing
for v in violations
_enforce_transmission(
model = model,
violation = v,
isf = model[:isf],
lodf = model[:lodf],
)
end
return
end
function _enforce_transmission(;
model::JuMP.Model,
violation::_Violation,
isf::Matrix{Float64},
lodf::Matrix{Float64},
)::Nothing
instance = model[:instance]
limit::Float64 = 0.0
overflow = model[:overflow]
net_injection = model[:net_injection]
if violation.outage_line === nothing
limit = violation.monitored_line.normal_flow_limit[violation.time]
@info @sprintf(
" %8.3f MW overflow in %-5s time %3d (pre-contingency)",
violation.amount,
violation.monitored_line.name,
violation.time,
)
else
limit = violation.monitored_line.emergency_flow_limit[violation.time]
@info @sprintf(
" %8.3f MW overflow in %-5s time %3d (outage: line %s)",
violation.amount,
violation.monitored_line.name,
violation.time,
violation.outage_line.name,
)
end
fm = violation.monitored_line.name
t = violation.time
flow = @variable(model, base_name = "flow[$fm,$t]")
v = overflow[violation.monitored_line.name, violation.time]
@constraint(model, flow <= limit + v)
@constraint(model, -flow <= limit + v)
if violation.outage_line === nothing
@constraint(
model,
flow == sum(
net_injection[b.name, violation.time] *
isf[violation.monitored_line.offset, b.offset] for
b in instance.buses if b.offset > 0
)
)
else
@constraint(
model,
flow == sum(
net_injection[b.name, violation.time] * (
isf[violation.monitored_line.offset, b.offset] + (
lodf[
violation.monitored_line.offset,
violation.outage_line.offset,
] * isf[violation.outage_line.offset, b.offset]
)
) for b in instance.buses if b.offset > 0
)
)
end
return nothing
end

@ -0,0 +1,44 @@
# UnitCommitment.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 _offer(filter::_ViolationFilter, v::_Violation)::Nothing
if v.monitored_line.offset keys(filter.queues)
filter.queues[v.monitored_line.offset] =
PriorityQueue{_Violation,Float64}()
end
q::PriorityQueue{_Violation,Float64} =
filter.queues[v.monitored_line.offset]
if length(q) < filter.max_per_line
enqueue!(q, v => v.amount)
else
if v.amount > peek(q)[1].amount
dequeue!(q)
enqueue!(q, v => v.amount)
end
end
return nothing
end
function _query(filter::_ViolationFilter)::Array{_Violation,1}
violations = Array{_Violation,1}()
time_queue = PriorityQueue{_Violation,Float64}()
for l in keys(filter.queues)
line_queue = filter.queues[l]
while length(line_queue) > 0
v = dequeue!(line_queue)
if length(time_queue) < filter.max_total
enqueue!(time_queue, v => v.amount)
else
if v.amount > peek(time_queue)[1].amount
dequeue!(time_queue)
enqueue!(time_queue, v => v.amount)
end
end
end
end
while length(time_queue) > 0
violations = [violations; dequeue!(time_queue)]
end
return violations
end

@ -1,91 +1,56 @@
# UnitCommitment.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.
# Copyright (C) 2019 Argonne National Laboratory
# Written by Alinson Santos Xavier <axavier@anl.gov>
using DataStructures
using Base.Threads
import Base.Threads: @threads
struct Violation
time::Int
monitored_line::TransmissionLine
outage_line::Union{TransmissionLine,Nothing}
amount::Float64 # Violation amount (in MW)
end
function Violation(;
time::Int,
monitored_line::TransmissionLine,
outage_line::Union{TransmissionLine,Nothing},
amount::Float64,
)::Violation
return Violation(time, monitored_line, outage_line, amount)
end
mutable struct ViolationFilter
max_per_line::Int
max_total::Int
queues::Dict{Int,PriorityQueue{Violation,Float64}}
end
function ViolationFilter(;
max_per_line::Int = 1,
max_total::Int = 5,
)::ViolationFilter
return ViolationFilter(max_per_line, max_total, Dict())
end
function _offer(filter::ViolationFilter, v::Violation)::Nothing
if v.monitored_line.offset keys(filter.queues)
filter.queues[v.monitored_line.offset] =
PriorityQueue{Violation,Float64}()
end
q::PriorityQueue{Violation,Float64} = filter.queues[v.monitored_line.offset]
if length(q) < filter.max_per_line
enqueue!(q, v => v.amount)
else
if v.amount > peek(q)[1].amount
dequeue!(q)
enqueue!(q, v => v.amount)
end
end
return nothing
end
function _query(filter::ViolationFilter)::Array{Violation,1}
violations = Array{Violation,1}()
time_queue = PriorityQueue{Violation,Float64}()
for l in keys(filter.queues)
line_queue = filter.queues[l]
while length(line_queue) > 0
v = dequeue!(line_queue)
if length(time_queue) < filter.max_total
enqueue!(time_queue, v => v.amount)
else
if v.amount > peek(time_queue)[1].amount
dequeue!(time_queue)
enqueue!(time_queue, v => v.amount)
end
end
end
end
while length(time_queue) > 0
violations = [violations; dequeue!(time_queue)]
function _find_violations(
model::JuMP.Model;
max_per_line::Int,
max_per_period::Int,
)
instance = model[:instance]
net_injection = model[:net_injection]
overflow = model[:overflow]
length(instance.buses) > 1 || return []
violations = []
@info "Verifying transmission limits..."
time_screening = @elapsed begin
non_slack_buses = [b for b in instance.buses if b.offset > 0]
net_injection_values = [
value(net_injection[b.name, t]) for b in non_slack_buses,
t in 1:instance.time
]
overflow_values = [
value(overflow[lm.name, t]) for lm in instance.lines,
t in 1:instance.time
]
violations = UnitCommitment._find_violations(
instance = instance,
net_injections = net_injection_values,
overflow = overflow_values,
isf = model[:isf],
lodf = model[:lodf],
max_per_line = max_per_line,
max_per_period = max_per_period,
)
end
@info @sprintf(
"Verified transmission limits in %.2f seconds",
time_screening
)
return violations
end
"""
function _find_violations(
instance::UnitCommitmentInstance,
net_injections::Array{Float64, 2};
isf::Array{Float64,2},
lodf::Array{Float64,2},
max_per_line::Int = 1,
max_per_period::Int = 5,
)::Array{Violation, 1}
max_per_line::Int,
max_per_period::Int,
)::Array{_Violation, 1}
Find transmission constraint violations (both pre-contingency, as well as
post-contingency).
@ -103,9 +68,9 @@ function _find_violations(;
overflow::Array{Float64,2},
isf::Array{Float64,2},
lodf::Array{Float64,2},
max_per_line::Int = 1,
max_per_period::Int = 5,
)::Array{Violation,1}
max_per_line::Int,
max_per_period::Int,
)::Array{_Violation,1}
B = length(instance.buses) - 1
L = length(instance.lines)
T = instance.time
@ -116,7 +81,7 @@ function _find_violations(;
size(lodf) == (L, L) || error("lodf has incorrect size")
filters = Dict(
t => ViolationFilter(
t => _ViolationFilter(
max_total = max_per_period,
max_per_line = max_per_line,
) for t in 1:T
@ -177,7 +142,7 @@ function _find_violations(;
if pre_v[lm, k] > 1e-5
_offer(
filters[t],
Violation(
_Violation(
time = t,
monitored_line = instance.lines[lm],
outage_line = nothing,
@ -192,7 +157,7 @@ function _find_violations(;
if post_v[lm, lc, k] > 1e-5 && is_vulnerable[lc]
_offer(
filters[t],
Violation(
_Violation(
time = t,
monitored_line = instance.lines[lm],
outage_line = instance.lines[lc],
@ -203,7 +168,7 @@ function _find_violations(;
end
end
violations = Violation[]
violations = _Violation[]
for t in 1:instance.time
append!(violations, _query(filters[t]))
end

@ -0,0 +1,67 @@
# UnitCommitment.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.
"""
optimize!(model::JuMP.Model, method::_XaQiWaTh19)::Nothing
Solve the given unit commitment model, enforcing transmission and N-1
security constraints lazily, according to the algorithm described in:
Xavier, A. S., Qiu, F., Wang, F., & Thimmapuram, P. R. (2019). Transmission
constraint filtering in large-scale security-constrained unit commitment.
IEEE Transactions on Power Systems, 34(3), 2457-2460.
"""
function optimize!(model::JuMP.Model, method::_XaQiWaTh19)::Nothing
function set_gap(gap)
try
JuMP.set_optimizer_attribute(model, "MIPGap", gap)
@info @sprintf("MIP gap tolerance set to %f", gap)
catch
@warn "Could not change MIP gap tolerance"
end
end
instance = model[:instance]
initial_time = time()
large_gap = false
has_transmission = (length(model[:isf]) > 0)
if has_transmission && method.two_phase_gap
set_gap(1e-2)
large_gap = true
else
set_gap(method.gap_limit)
end
while true
time_elapsed = time() - initial_time
time_remaining = method.time_limit - time_elapsed
if time_remaining < 0
@info "Time limit exceeded"
break
end
@info @sprintf(
"Setting MILP time limit to %.2f seconds",
time_remaining
)
JuMP.set_time_limit_sec(model, time_remaining)
@info "Solving MILP..."
JuMP.optimize!(model)
has_transmission || break
violations = _find_violations(
model,
max_per_line = method.max_violations_per_line,
max_per_period = method.max_violations_per_period,
)
if isempty(violations)
@info "No violations found"
if large_gap
large_gap = false
set_gap(method.gap_limit)
else
break
end
else
_enforce_transmission(model, violations)
end
end
return
end

@ -0,0 +1,78 @@
# UnitCommitment.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.
import DataStructures: PriorityQueue
"""
struct _XaQiWaTh19 <: SolutionMethod
time_limit::Float64
gap_limit::Float64
two_phase_gap::Bool
end
Lazy constraint solution method described in:
Xavier, A. S., Qiu, F., Wang, F., & Thimmapuram, P. R. (2019). Transmission
constraint filtering in large-scale security-constrained unit commitment.
IEEE Transactions on Power Systems, 34(3), 2457-2460.
Fields
=========
- `time_limit`:
the time limit over the entire optimization procedure.
- `gap_limit`:
the desired relative optimality gap.
- `two_phase_gap`:
if true, solve the problem with large gap tolerance first, then reduce
the gap tolerance when no further violated constraints are found.
"""
struct _XaQiWaTh19
time_limit::Float64
gap_limit::Float64
two_phase_gap::Bool
max_violations_per_line::Int
max_violations_per_period::Int
function _XaQiWaTh19(;
time_limit::Float64,
gap_limit::Float64,
two_phase_gap::Bool,
max_violations_per_line::Int,
max_violations_per_period::Int,
)
return new(
time_limit,
gap_limit,
two_phase_gap,
max_violations_per_line,
max_violations_per_period,
)
end
end
struct _Violation
time::Int
monitored_line::TransmissionLine
outage_line::Union{TransmissionLine,Nothing}
amount::Float64
function _Violation(;
time::Int,
monitored_line::TransmissionLine,
outage_line::Union{TransmissionLine,Nothing},
amount::Float64,
)
return new(time, monitored_line, outage_line, amount)
end
end
mutable struct _ViolationFilter
max_per_line::Int
max_total::Int
queues::Dict{Int,PriorityQueue{_Violation,Float64}}
function _ViolationFilter(; max_per_line::Int = 1, max_total::Int = 5)
return new(max_per_line, max_total, Dict())
end
end

@ -0,0 +1,23 @@
# UnitCommitment.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 optimize!(model::JuMP.Model)::Nothing
Solve the given unit commitment model. Unlike JuMP.optimize!, this uses more
advanced methods to accelerate the solution process and to enforce transmission
and N-1 security constraints.
"""
function optimize!(model::JuMP.Model)::Nothing
return UnitCommitment.optimize!(
model,
_XaQiWaTh19(
time_limit = 3600.0,
gap_limit = 1e-4,
two_phase_gap = true,
max_violations_per_line = 1,
max_violations_per_period = 5,
),
)
end

@ -0,0 +1,65 @@
# UnitCommitment.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 solution(model::JuMP.Model)::OrderedDict
instance, T = model[:instance], model[:instance].time
function timeseries(vars, collection)
return OrderedDict(
b.name => [round(value(vars[b.name, t]), digits = 5) for t in 1:T]
for b in collection
)
end
function production_cost(g)
return [
value(model[:is_on][g.name, t]) * g.min_power_cost[t] + sum(
Float64[
value(model[:segprod][g.name, t, k]) *
g.cost_segments[k].cost[t] for
k in 1:length(g.cost_segments)
],
) for t in 1:T
]
end
function production(g)
return [
value(model[:is_on][g.name, t]) * g.min_power[t] + sum(
Float64[
value(model[:segprod][g.name, t, k]) for
k in 1:length(g.cost_segments)
],
) for t in 1:T
]
end
function startup_cost(g)
S = length(g.startup_categories)
return [
sum(
g.startup_categories[s].cost *
value(model[:startup][g.name, t, s]) for s in 1:S
) for t in 1:T
]
end
sol = OrderedDict()
sol["Production (MW)"] =
OrderedDict(g.name => production(g) for g in instance.units)
sol["Production cost (\$)"] =
OrderedDict(g.name => production_cost(g) for g in instance.units)
sol["Startup cost (\$)"] =
OrderedDict(g.name => startup_cost(g) for g in instance.units)
sol["Is on"] = timeseries(model[:is_on], instance.units)
sol["Switch on"] = timeseries(model[:switch_on], instance.units)
sol["Switch off"] = timeseries(model[:switch_off], instance.units)
sol["Reserve (MW)"] = timeseries(model[:reserve], instance.units)
sol["Net injection (MW)"] =
timeseries(model[:net_injection], instance.buses)
sol["Load curtail (MW)"] = timeseries(model[:curtail], instance.buses)
if !isempty(instance.lines)
sol["Line overflow (MW)"] = timeseries(model[:overflow], instance.lines)
end
if !isempty(instance.price_sensitive_loads)
sol["Price-sensitive loads (MW)"] =
timeseries(model[:loads], instance.price_sensitive_loads)
end
return sol
end

@ -0,0 +1,5 @@
# UnitCommitment.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.
abstract type SolutionMethod end

@ -0,0 +1,24 @@
# UnitCommitment.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 set_warm_start!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
is_on = model[:is_on]
prod_above = model[:prod_above]
reserve = model[:reserve]
for g in instance.units
for t in 1:T
JuMP.set_start_value(is_on[g.name, t], solution["Is on"][g.name][t])
JuMP.set_start_value(
switch_on[g.name, t],
solution["Switch on"][g.name][t],
)
JuMP.set_start_value(
switch_off[g.name, t],
solution["Switch off"][g.name][t],
)
end
end
return
end

@ -0,0 +1,10 @@
# UnitCommitment.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 write(filename::AbstractString, solution::AbstractDict)::Nothing
open(filename, "w") do file
return JSON.print(file, solution, 2)
end
return
end

@ -0,0 +1,52 @@
# UnitCommitment.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.
"""
slice(instance, range)
Creates a new instance, with only a subset of the time periods.
This function does not modify the provided instance. The initial
conditions are also not modified.
Example
-------
# Build a 2-hour UC instance
instance = UnitCommitment.read_benchmark("test/case14")
modified = UnitCommitment.slice(instance, 1:2)
"""
function slice(
instance::UnitCommitmentInstance,
range::UnitRange{Int},
)::UnitCommitmentInstance
modified = deepcopy(instance)
modified.time = length(range)
modified.power_balance_penalty = modified.power_balance_penalty[range]
modified.reserves.spinning = modified.reserves.spinning[range]
for u in modified.units
u.max_power = u.max_power[range]
u.min_power = u.min_power[range]
u.must_run = u.must_run[range]
u.min_power_cost = u.min_power_cost[range]
u.provides_spinning_reserves = u.provides_spinning_reserves[range]
for s in u.cost_segments
s.mw = s.mw[range]
s.cost = s.cost[range]
end
end
for b in modified.buses
b.load = b.load[range]
end
for l in modified.lines
l.normal_flow_limit = l.normal_flow_limit[range]
l.emergency_flow_limit = l.emergency_flow_limit[range]
l.flow_limit_penalty = l.flow_limit_penalty[range]
end
for ps in modified.price_sensitive_loads
ps.demand = ps.demand[range]
ps.revenue = ps.revenue[range]
end
return modified
end

@ -0,0 +1,3 @@
# UnitCommitment.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.

@ -0,0 +1,69 @@
# UnitCommitment.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.
"""
repair!(instance)
Verifies that the given unit commitment instance is valid and automatically
fixes some validation errors if possible, issuing a warning for each error
found. If a validation error cannot be automatically fixed, issues an
exception.
Returns the number of validation errors found.
"""
function repair!(instance::UnitCommitmentInstance)::Int
n_errors = 0
for g in instance.units
# Startup costs and delays must be increasing
for s in 2:length(g.startup_categories)
if g.startup_categories[s].delay <= g.startup_categories[s-1].delay
prev_value = g.startup_categories[s].delay
new_value = g.startup_categories[s-1].delay + 1
@warn "Generator $(g.name) has non-increasing startup delays (category $s). " *
"Changing delay: $prev_value$new_value"
g.startup_categories[s].delay = new_value
n_errors += 1
end
if g.startup_categories[s].cost < g.startup_categories[s-1].cost
prev_value = g.startup_categories[s].cost
new_value = g.startup_categories[s-1].cost
@warn "Generator $(g.name) has decreasing startup cost (category $s). " *
"Changing cost: $prev_value$new_value"
g.startup_categories[s].cost = new_value
n_errors += 1
end
end
for t in 1:instance.time
# Production cost curve should be convex
for k in 2:length(g.cost_segments)
cost = g.cost_segments[k].cost[t]
min_cost = g.cost_segments[k-1].cost[t]
if cost < min_cost - 1e-5
@warn "Generator $(g.name) has non-convex production cost curve " *
"(segment $k, time $t). Changing cost: $cost$min_cost"
g.cost_segments[k].cost[t] = min_cost
n_errors += 1
end
end
# Startup limit must be greater than min_power
if g.startup_limit < g.min_power[t]
new_limit = g.min_power[t]
prev_limit = g.startup_limit
@warn "Generator $(g.name) has startup limit lower than minimum power. " *
"Changing startup limit: $prev_limit$new_limit"
g.startup_limit = new_limit
n_errors += 1
end
end
end
return n_errors
end
export repair!

@ -6,70 +6,6 @@ using Printf
bin(x) = [xi > 0.5 for xi in x]
"""
repair!(instance)
Verifies that the given unit commitment instance is valid and automatically
fixes some validation errors if possible, issuing a warning for each error
found. If a validation error cannot be automatically fixed, issues an
exception.
Returns the number of validation errors found.
"""
function repair!(instance::UnitCommitmentInstance)::Int
n_errors = 0
for g in instance.units
# Startup costs and delays must be increasing
for s in 2:length(g.startup_categories)
if g.startup_categories[s].delay <= g.startup_categories[s-1].delay
prev_value = g.startup_categories[s].delay
new_value = g.startup_categories[s-1].delay + 1
@warn "Generator $(g.name) has non-increasing startup delays (category $s). " *
"Changing delay: $prev_value$new_value"
g.startup_categories[s].delay = new_value
n_errors += 1
end
if g.startup_categories[s].cost < g.startup_categories[s-1].cost
prev_value = g.startup_categories[s].cost
new_value = g.startup_categories[s-1].cost
@warn "Generator $(g.name) has decreasing startup cost (category $s). " *
"Changing cost: $prev_value$new_value"
g.startup_categories[s].cost = new_value
n_errors += 1
end
end
for t in 1:instance.time
# Production cost curve should be convex
for k in 2:length(g.cost_segments)
cost = g.cost_segments[k].cost[t]
min_cost = g.cost_segments[k-1].cost[t]
if cost < min_cost - 1e-5
@warn "Generator $(g.name) has non-convex production cost curve " *
"(segment $k, time $t). Changing cost: $cost$min_cost"
g.cost_segments[k].cost[t] = min_cost
n_errors += 1
end
end
# Startup limit must be greater than min_power
if g.startup_limit < g.min_power[t]
new_limit = g.min_power[t]
prev_limit = g.startup_limit
@warn "Generator $(g.name) has startup limit lower than minimum power. " *
"Changing startup limit: $prev_limit$new_limit"
g.startup_limit = new_limit
n_errors += 1
end
end
end
return n_errors
end
function validate(instance_filename::String, solution_filename::String)
instance = UnitCommitment.read(instance_filename)
solution = JSON.parse(open(solution_filename))

@ -7,7 +7,7 @@ using UnitCommitment
@testset "convert" begin
@testset "EGRET solution" begin
solution =
UnitCommitment._read_egret_solution("fixtures/egret_output.json.gz")
UnitCommitment.read_egret_solution("fixtures/egret_output.json.gz")
for attr in ["Is on", "Production (MW)", "Production cost (\$)"]
@test attr in keys(solution)
@test "115_STEAM_1" in keys(solution[attr])

@ -149,7 +149,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
# Should be able to build model without errors
optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0)
model = build_model(
model = UnitCommitment.build_model(
instance = modified,
optimizer = optimizer,
variable_names = true,

@ -11,7 +11,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP
line.normal_flow_limit[t] = 10.0
end
optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0)
model = build_model(
model = UnitCommitment.build_model(
instance = instance,
optimizer = optimizer,
variable_names = true,

@ -3,16 +3,17 @@
# Released under the modified BSD license. See COPYING.md for more details.
using UnitCommitment, Test, LinearAlgebra
import UnitCommitment: Violation, _offer, _query
import UnitCommitment: _Violation, _offer, _query
@testset "Screening" begin
@testset "Violation filter" begin
@testset "_Violation filter" begin
instance = UnitCommitment.read_benchmark("test/case14")
filter = UnitCommitment.ViolationFilter(max_per_line = 1, max_total = 2)
filter =
UnitCommitment._ViolationFilter(max_per_line = 1, max_total = 2)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[1],
outage_line = nothing,
@ -21,7 +22,7 @@ import UnitCommitment: Violation, _offer, _query
)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[1],
outage_line = instance.lines[1],
@ -30,7 +31,7 @@ import UnitCommitment: Violation, _offer, _query
)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[1],
outage_line = instance.lines[5],
@ -39,7 +40,7 @@ import UnitCommitment: Violation, _offer, _query
)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[1],
outage_line = instance.lines[4],
@ -48,7 +49,7 @@ import UnitCommitment: Violation, _offer, _query
)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[2],
outage_line = instance.lines[1],
@ -57,7 +58,7 @@ import UnitCommitment: Violation, _offer, _query
)
_offer(
filter,
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[2],
outage_line = instance.lines[8],
@ -67,13 +68,13 @@ import UnitCommitment: Violation, _offer, _query
actual = _query(filter)
expected = [
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[2],
outage_line = instance.lines[1],
amount = 200.0,
),
Violation(
_Violation(
time = 1,
monitored_line = instance.lines[1],
outage_line = instance.lines[5],
@ -106,6 +107,8 @@ import UnitCommitment: Violation, _offer, _query
overflow = overflow,
isf = isf,
lodf = lodf,
max_per_line = 1,
max_per_period = 5,
)
@test length(violations) == 20
end

Loading…
Cancel
Save