Merge pull request #26 from hejun0524/dev

LMP Methods & Profiled Units
pull/29/head
Alinson S. Xavier 3 years ago committed by GitHub
commit 319a787904
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -1,4 +1,5 @@
[deps]
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
UnitCommitment = "64606440-39ea-11e9-0f29-3303a1d3d877"

@ -1,4 +1,4 @@
using Documenter, UnitCommitment
using Documenter, UnitCommitment, JuMP
makedocs(
sitename="UnitCommitment.jl",

@ -12,6 +12,20 @@ UnitCommitment.validate
UnitCommitment.write
```
## Locational Marginal Prices
### Conventional LMPs
```@docs
UnitCommitment.compute_lmp(::JuMP.Model,::UnitCommitment.ConventionalLMP)
```
### Approximated Extended LMPs
```@docs
UnitCommitment.AELMP
UnitCommitment.compute_lmp(::JuMP.Model,::UnitCommitment.AELMP)
```
## Modify instance
```@docs

@ -70,11 +70,14 @@ This section describes the characteristics of each bus in the system.
### Generators
This section describes all generators in the system, including thermal units, renewable units and virtual units.
This section describes all generators in the system, including thermal units, renewable units and virtual units. Two types of generators can be specified - thermal units and profiled units. A thermal unit consists of different fields, while a profiled unit is a simple generator with only a production capacity and a per-unit cost.
#### Thermal Units
| Key | Description | Default | Time series?
| :------------------------ | :------------------------------------------------| ------- | :-----------:
| `Bus` | Identifier of the bus where this generator is located (string). | Required | N
| `Type` | Type of the generator (string). For thermal generators, this must be `Thermal`. | Required | N
| `Production cost curve (MW)` and `Production cost curve ($)` | Parameters describing the piecewise-linear production costs. See below for more details. | Required | Y
| `Startup costs ($)` and `Startup delays (h)` | Parameters describing how much it costs to start the generator after it has been shut down for a certain amount of time. If `Startup costs ($)` and `Startup delays (h)` are set to `[300.0, 400.0]` and `[1, 4]`, for example, and the generator is shut down at time `00:00` (h:min), then it costs \$300 to start up the generator at any time between `01:00` and `03:59`, and \$400 to start the generator at time `04:00` or any time after that. The number of startup cost points is unlimited, and may be different for each generator. Startup delays must be strictly increasing and the first entry must equal `Minimum downtime (h)`. | `[0.0]` and `[1]` | N
| `Minimum uptime (h)` | Minimum amount of time the generator must stay operational after starting up (in hours). For example, if the generator starts up at time `00:00` (h:min) and `Minimum uptime (h)` is set to 4, then the generator can only shut down at time `04:00`. | `1` | N
@ -88,6 +91,15 @@ This section describes all generators in the system, including thermal units, re
| `Must run?` | If `true`, the generator should be committed, even if that is not economical (Boolean). | `false` | Y
| `Reserve eligibility` | List of reserve products this generator is eligibe to provide. By default, the generator is not eligible to provide any reserves. | `[]` | N
#### Profiled Units
| Key | Description | Default | Time series?
| :---------------- | :------------------------------------------------ | :------: | :------------:
| `Bus` | Identifier of the bus where this generator is located (string). | Required | N
| `Type` | Type of the generator (string). For profiled generators, this must be `Profiled`. | Required | N
| `Cost ($/MW)` | Cost incurred for serving each MW of power by this generator. | Required | Y
| `Maximum Capacity (MW)` | Maximum amount of power to be supplied by this generator. Any amount lower than this may be supplied. | Required | Y
#### Production costs and limits
Production costs are represented as piecewise-linear curves. Figure 1 shows an example cost curve with three segments, where it costs \$1400, \$1600, \$2200 and \$2400 to generate, respectively, 100, 110, 130 and 135 MW of power. To model this generator, `Production cost curve (MW)` should be set to `[100, 110, 130, 135]`, and `Production cost curve ($)` should be set to `[1400, 1600, 2200, 2400]`.
@ -115,6 +127,7 @@ Note that this curve also specifies the production limits. Specifically, the fir
"Generators": {
"gen1": {
"Bus": "b1",
"Type": "Thermal",
"Production cost curve (MW)": [100.0, 110.0, 130.0, 135.0],
"Production cost curve ($)": [1400.0, 1600.0, 2200.0, 2400.0],
"Startup costs ($)": [300.0, 400.0],
@ -126,14 +139,24 @@ Note that this curve also specifies the production limits. Specifically, the fir
"Minimum downtime (h)": 4,
"Minimum uptime (h)": 4,
"Initial status (h)": 12,
"Initial power (MW)": 115,
"Must run?": false,
"Reserve eligibility": ["r1"],
"Reserve eligibility": ["r1"]
},
"gen2": {
"Bus": "b5",
"Type": "Thermal",
"Production cost curve (MW)": [0.0, [10.0, 8.0, 0.0, 3.0]],
"Production cost curve ($)": [0.0, 0.0],
"Reserve eligibility": ["r1", "r2"],
"Initial status (h)": -100,
"Initial power (MW)": 0,
"Reserve eligibility": ["r1", "r2"]
},
"gen3": {
"Bus": "b6",
"Type": "Profiled",
"Maximum power (MW)": 120.0,
"Cost ($/MW)": 100.0
}
}
}

@ -20,6 +20,7 @@ Depth = 3
* **Alinson S. Xavier** (Argonne National Laboratory)
* **Aleksandr M. Kazachkov** (University of Florida)
* **Ogün Yurdakul** (Technische Universität Berlin)
* **Jun He** (Purdue University)
* **Feng Qiu** (Argonne National Laboratory)
## Acknowledgments

@ -8,6 +8,8 @@ Decision variables
### Generators
#### Thermal Units
Name | Symbol | Description | Unit
:-----|:--------:|:-------------|:------:
`is_on[g,t]` | $u_{g}(t)$ | True if generator `g` is on at time `t`. | Binary
@ -19,6 +21,13 @@ Name | Symbol | Description | Unit
`startup[g,t,s]` | $\delta^s_g(t)$ | True if generator `g` switches on at time `t` incurring start-up costs from start-up category `s`. | Binary
#### Profiled Units
Name | Symbol | Description | Unit
:-----|:------:|:-------------|:------:
`prod_profiled[s,t]` | $p^{\dagger}_{g}(t)$ | Amount of power produced by profiled unit `g` at time `t`. | MW
### Buses
Name | Symbol | Description | Unit

@ -58,10 +58,7 @@ using UnitCommitment
instance = UnitCommitment.read_benchmark("matpower/case3375wp/2017-02-01")
```
Advanced usage
--------------
### Customizing the formulation
## Customizing the formulation
By default, `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.
@ -94,7 +91,7 @@ model = UnitCommitment.build_model(
)
```
### Generating initial conditions
## Generating initial conditions
When creating random unit commitment instances for benchmark purposes, it is often hard to compute, in advance, sensible initial conditions for all generators. Setting initial conditions naively (for example, making all generators initially off and producing no power) can easily cause the instance to become infeasible due to excessive ramping. Initial conditions can also make it hard to modify existing instances. For example, increasing the system load without carefully modifying the initial conditions may make the problem infeasible or unrealistically challenging to solve.
@ -122,7 +119,7 @@ UnitCommitment.optimize!(model)
The function `generate_initial_conditions!` may return different initial conditions after each call, even if the same instance and the same optimizer is provided. The particular algorithm may also change in a future version of UC.jl. For these reasons, it is recommended that you generate initial conditions exactly once for each instance and store them for later use.
### Verifying solutions
## Verifying solutions
When developing new formulations, it is very easy to introduce subtle errors in the model that result in incorrect solutions. To help with this, UC.jl includes a utility function that verifies if a given solution is feasible, and, if not, prints all the validation errors it found. The implementation of this function is completely independent from the implementation of the optimization model, and therefore can be used to validate it. The function can also be used to verify solutions produced by other optimization packages, as long as they follow the [UC.jl data format](format.md).
@ -139,3 +136,91 @@ solution = JSON.parsefile("solution.json")
# Validate solution and print validation errors
UnitCommitment.validate(instance, solution)
```
## Computing Locational Marginal Prices
Locational marginal prices (LMPs) refer to the cost of supplying electricity at a particular location of the network. Multiple methods for computing LMPs have been proposed in the literature. UnitCommitment.jl implements two commonly-used methods: conventional LMPs and Approximated Extended LMPs (AELMPs). To compute LMPs for a given unit commitment instance, the `compute_lmp` function can be used, as shown in the examples below. The function accepts three arguments -- a solved SCUC model, an LMP method, and a linear optimizer -- and it returns a dictionary mapping `(bus_name, time)` to the marginal price.
!!! warning
Most mixed-integer linear optimizers, such as `HiGHS`, `Gurobi` and `CPLEX` can be used with `compute_lmp`, with the notable exception of `Cbc`, which does not support dual value evaluations. If using `Cbc`, please provide `Clp` as the linear optimizer.
### Conventional LMPs
LMPs are conventionally computed by: (1) solving the SCUC model, (2) fixing all binary variables to their optimal values, and (3) re-solving the resulting linear programming model. In this approach, the LMPs are defined as the dual variables' values associated with the net injection constraints. The example below shows how to compute conventional LMPs for a given unit commitment instance. First, we build and optimize the SCUC model. Then, we call the `compute_lmp` function, providing as the second argument `ConventionalLMP()`.
```julia
using UnitCommitment
using HiGHS
import UnitCommitment: ConventionalLMP
# Read benchmark instance
instance = UnitCommitment.read_benchmark("matpower/case118/2018-01-01")
# Build the model
model = UnitCommitment.build_model(
instance = instance,
optimizer = HiGHS.Optimizer,
)
# Optimize the model
UnitCommitment.optimize!(model)
# Compute the LMPs using the conventional method
lmp = UnitCommitment.compute_lmp(
model,
ConventionalLMP(),
optimizer = HiGHS.Optimizer,
)
# Access the LMPs
# Example: "s1" is the scenario name, "b1" is the bus name, 1 is the first time slot
@show lmp["s1","b1", 1]
```
### Approximate Extended LMPs
Approximate Extended LMPs (AELMPs) are an alternative method to calculate locational marginal prices which attemps to minimize uplift payments. The method internally works by modifying the instance data in three ways: (1) it sets the minimum power output of each generator to zero, (2) it averages the start-up cost over the offer blocks for each generator, and (3) it relaxes all integrality constraints. To compute AELMPs, as shown in the example below, we call `compute_lmp` and provide `AELMP()` as the second argument.
This method has two configurable parameters: `allow_offline_participation` and `consider_startup_costs`. If `allow_offline_participation = true`, then offline generators are allowed to participate in the pricing. If instead `allow_offline_participation = false`, offline generators are not allowed and therefore are excluded from the system. A solved UC model is optional if offline participation is allowed, but is required if not allowed. The method forces offline participation to be allowed if the UC model supplied by the user is not solved. For the second field, If `consider_startup_costs = true`, then start-up costs are integrated and averaged over each unit production; otherwise the production costs stay the same. By default, both fields are set to `true`.
!!! warning
This approximation method is still under active research, and has several limitations. The implementation provided in the package is based on MISO Phase I only. It only supports fast start resources. More specifically, the minimum up/down time of all generators must be 1, the initial power of all generators must be 0, and the initial status of all generators must be negative. The method does not support time-varying start-up costs. The method does not support multiple scenarios. If offline participation is not allowed, AELMPs treats an asset to be offline if it is never on throughout all time periods.
```julia
using UnitCommitment
using HiGHS
import UnitCommitment: AELMP
# Read benchmark instance
instance = UnitCommitment.read_benchmark("matpower/case118/2017-02-01")
# Build the model
model = UnitCommitment.build_model(
instance = instance,
optimizer = HiGHS.Optimizer,
)
# Optimize the model
UnitCommitment.optimize!(model)
# Compute the AELMPs
aelmp = UnitCommitment.compute_lmp(
model,
AELMP(
allow_offline_participation = false,
consider_startup_costs = true
),
optimizer = HiGHS.Optimizer
)
# Access the AELMPs
# Example: "s1" is the scenario name, "b1" is the bus name, 1 is the first time slot
# Note: although scenario is supported, the query still keeps the scenario keys for consistency.
@show aelmp["s1", "b1", 1]
```

@ -9,6 +9,7 @@ using Base: String
include("instance/structs.jl")
include("model/formulations/base/structs.jl")
include("solution/structs.jl")
include("lmp/structs.jl")
include("model/formulations/ArrCon2000/structs.jl")
include("model/formulations/CarArr2006/structs.jl")
@ -31,6 +32,7 @@ include("model/formulations/base/psload.jl")
include("model/formulations/base/sensitivity.jl")
include("model/formulations/base/system.jl")
include("model/formulations/base/unit.jl")
include("model/formulations/base/punit.jl")
include("model/formulations/CarArr2006/pwlcosts.jl")
include("model/formulations/DamKucRajAta2016/ramp.jl")
include("model/formulations/Gar1962/pwlcosts.jl")
@ -58,5 +60,7 @@ include("utils/log.jl")
include("utils/benchmark.jl")
include("validation/repair.jl")
include("validation/validate.jl")
include("lmp/conventional.jl")
include("lmp/aelmp.jl")
end

@ -18,9 +18,9 @@ function read_egret_solution(path::String)::OrderedDict
solution = OrderedDict()
is_on = solution["Is on"] = OrderedDict()
production = solution["Production (MW)"] = OrderedDict()
production = solution["Thermal production (MW)"] = OrderedDict()
reserve = solution["Reserve (MW)"] = OrderedDict()
production_cost = solution["Production cost (\$)"] = OrderedDict()
production_cost = solution["Thermal production cost (\$)"] = OrderedDict()
startup_cost = solution["Startup cost (\$)"] = OrderedDict()
for (gen_name, gen_dict) in egret["elements"]["generator"]

@ -17,6 +17,7 @@ function _migrate(json)
end
version = VersionNumber(version)
version >= v"0.3" || _migrate_to_v03(json)
version >= v"0.4" || _migrate_to_v04(json)
return
end
@ -36,3 +37,14 @@ function _migrate_to_v03(json)
end
end
end
function _migrate_to_v04(json)
# Migrate thermal units
if json["Generators"] !== nothing
for (gen_name, gen) in json["Generators"]
if gen["Type"] === nothing
gen["Type"] = "Thermal"
end
end
end
end

@ -129,12 +129,13 @@ end
function _from_json(json; repair = true)::UnitCommitmentScenario
_migrate(json)
units = Unit[]
thermal_units = ThermalUnit[]
buses = Bus[]
contingencies = Contingency[]
lines = TransmissionLine[]
loads = PriceSensitiveLoad[]
reserves = Reserve[]
profiled_units = ProfiledUnit[]
function scalar(x; default = nothing)
x !== nothing || return default
@ -159,7 +160,7 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
name_to_bus = Dict{String,Bus}()
name_to_line = Dict{String,TransmissionLine}()
name_to_unit = Dict{String,Unit}()
name_to_unit = Dict{String,ThermalUnit}()
name_to_reserve = Dict{String,Reserve}()
function timeseries(x; default = nothing)
@ -180,8 +181,9 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
bus_name,
length(buses),
timeseries(dict["Load (MW)"]),
Unit[],
ThermalUnit[],
PriceSensitiveLoad[],
ProfiledUnit[],
)
name_to_bus[bus_name] = bus
push!(buses, bus)
@ -194,7 +196,7 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
name = reserve_name,
type = lowercase(dict["Type"]),
amount = timeseries(dict["Amount (MW)"]),
units = [],
thermal_units = [],
shortfall_penalty = scalar(
dict["Shortfall penalty (\$/MW)"],
default = -1,
@ -207,90 +209,119 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
# Read units
for (unit_name, dict) in json["Generators"]
# Read and validate unit type
unit_type = scalar(dict["Type"], default = nothing)
unit_type !== nothing || error("unit $unit_name has no type specified")
bus = name_to_bus[dict["Bus"]]
# Read production cost curve
K = length(dict["Production cost curve (MW)"])
curve_mw = hcat(
[timeseries(dict["Production cost curve (MW)"][k]) for k in 1:K]...,
)
curve_cost = hcat(
[timeseries(dict["Production cost curve (\$)"][k]) for k in 1:K]...,
)
min_power = curve_mw[:, 1]
max_power = curve_mw[:, K]
min_power_cost = curve_cost[:, 1]
segments = CostSegment[]
for k in 2:K
amount = curve_mw[:, k] - curve_mw[:, k-1]
cost = (curve_cost[:, k] - curve_cost[:, k-1]) ./ amount
replace!(cost, NaN => 0.0)
push!(segments, CostSegment(amount, cost))
end
# Read startup costs
startup_delays = scalar(dict["Startup delays (h)"], default = [1])
startup_costs = scalar(dict["Startup costs (\$)"], default = [0.0])
startup_categories = StartupCategory[]
for k in 1:length(startup_delays)
push!(
startup_categories,
StartupCategory(
startup_delays[k] .* time_multiplier,
startup_costs[k],
),
if lowercase(unit_type) === "thermal"
# Read production cost curve
K = length(dict["Production cost curve (MW)"])
curve_mw = hcat(
[
timeseries(dict["Production cost curve (MW)"][k]) for
k in 1:K
]...,
)
end
curve_cost = hcat(
[
timeseries(dict["Production cost curve (\$)"][k]) for
k in 1:K
]...,
)
min_power = curve_mw[:, 1]
max_power = curve_mw[:, K]
min_power_cost = curve_cost[:, 1]
segments = CostSegment[]
for k in 2:K
amount = curve_mw[:, k] - curve_mw[:, k-1]
cost = (curve_cost[:, k] - curve_cost[:, k-1]) ./ amount
replace!(cost, NaN => 0.0)
push!(segments, CostSegment(amount, cost))
end
# Read reserve eligibility
unit_reserves = Reserve[]
if "Reserve eligibility" in keys(dict)
unit_reserves =
[name_to_reserve[n] for n in dict["Reserve eligibility"]]
end
# Read startup costs
startup_delays = scalar(dict["Startup delays (h)"], default = [1])
startup_costs = scalar(dict["Startup costs (\$)"], default = [0.0])
startup_categories = StartupCategory[]
for k in 1:length(startup_delays)
push!(
startup_categories,
StartupCategory(
startup_delays[k] .* time_multiplier,
startup_costs[k],
),
)
end
# Read and validate initial conditions
initial_power = scalar(dict["Initial power (MW)"], default = nothing)
initial_status = scalar(dict["Initial status (h)"], default = nothing)
if initial_power === nothing
initial_status === nothing ||
error("unit $unit_name has initial status but no initial power")
else
initial_status !== nothing ||
error("unit $unit_name has initial power but no initial status")
initial_status != 0 ||
error("unit $unit_name has invalid initial status")
if initial_status < 0 && initial_power > 1e-3
error("unit $unit_name has invalid initial power")
# Read reserve eligibility
unit_reserves = Reserve[]
if "Reserve eligibility" in keys(dict)
unit_reserves =
[name_to_reserve[n] for n in dict["Reserve eligibility"]]
end
initial_status *= time_multiplier
end
unit = Unit(
unit_name,
bus,
max_power,
min_power,
timeseries(dict["Must run?"], default = [false for t in 1:T]),
min_power_cost,
segments,
scalar(dict["Minimum uptime (h)"], default = 1) * time_multiplier,
scalar(dict["Minimum downtime (h)"], default = 1) * time_multiplier,
scalar(dict["Ramp up limit (MW)"], default = 1e6),
scalar(dict["Ramp down limit (MW)"], default = 1e6),
scalar(dict["Startup limit (MW)"], default = 1e6),
scalar(dict["Shutdown limit (MW)"], default = 1e6),
initial_status,
initial_power,
startup_categories,
unit_reserves,
)
push!(bus.units, unit)
for r in unit_reserves
push!(r.units, unit)
# Read and validate initial conditions
initial_power =
scalar(dict["Initial power (MW)"], default = nothing)
initial_status =
scalar(dict["Initial status (h)"], default = nothing)
if initial_power === nothing
initial_status === nothing || error(
"unit $unit_name has initial status but no initial power",
)
else
initial_status !== nothing || error(
"unit $unit_name has initial power but no initial status",
)
initial_status != 0 ||
error("unit $unit_name has invalid initial status")
if initial_status < 0 && initial_power > 1e-3
error("unit $unit_name has invalid initial power")
end
initial_status *= time_multiplier
end
unit = ThermalUnit(
unit_name,
bus,
max_power,
min_power,
timeseries(dict["Must run?"], default = [false for t in 1:T]),
min_power_cost,
segments,
scalar(dict["Minimum uptime (h)"], default = 1) *
time_multiplier,
scalar(dict["Minimum downtime (h)"], default = 1) *
time_multiplier,
scalar(dict["Ramp up limit (MW)"], default = 1e6),
scalar(dict["Ramp down limit (MW)"], default = 1e6),
scalar(dict["Startup limit (MW)"], default = 1e6),
scalar(dict["Shutdown limit (MW)"], default = 1e6),
initial_status,
initial_power,
startup_categories,
unit_reserves,
)
push!(bus.thermal_units, unit)
for r in unit_reserves
push!(r.thermal_units, unit)
end
name_to_unit[unit_name] = unit
push!(thermal_units, unit)
elseif lowercase(unit_type) === "profiled"
bus = name_to_bus[dict["Bus"]]
pu = ProfiledUnit(
unit_name,
bus,
timeseries(dict["Maximum power (MW)"]),
timeseries(dict["Cost (\$/MW)"]),
)
push!(bus.profiled_units, pu)
push!(profiled_units, pu)
else
error("unit $unit_name has an invalid type")
end
name_to_unit[unit_name] = unit
push!(units, unit)
end
# Read transmission lines
@ -324,7 +355,7 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
# Read contingencies
if "Contingencies" in keys(json)
for (cont_name, dict) in json["Contingencies"]
affected_units = Unit[]
affected_units = ThermalUnit[]
affected_lines = TransmissionLine[]
if "Affected lines" in keys(dict)
affected_lines =
@ -369,8 +400,10 @@ function _from_json(json; repair = true)::UnitCommitmentScenario
reserves = reserves,
reserves_by_name = name_to_reserve,
time = T,
units_by_name = Dict(g.name => g for g in units),
units = units,
thermal_units_by_name = Dict(g.name => g for g in thermal_units),
thermal_units = thermal_units,
profiled_units_by_name = Dict(pu.name => pu for pu in profiled_units),
profiled_units = profiled_units,
isf = spzeros(Float64, length(lines), length(buses) - 1),
lodf = spzeros(Float64, length(lines), length(lines)),
)

@ -6,8 +6,9 @@ mutable struct Bus
name::String
offset::Int
load::Vector{Float64}
units::Vector
thermal_units::Vector
price_sensitive_loads::Vector
profiled_units::Vector
end
mutable struct CostSegment
@ -24,11 +25,11 @@ Base.@kwdef mutable struct Reserve
name::String
type::String
amount::Vector{Float64}
units::Vector
thermal_units::Vector
shortfall_penalty::Float64
end
mutable struct Unit
mutable struct ThermalUnit
name::String
bus::Bus
max_power::Vector{Float64}
@ -63,7 +64,7 @@ end
mutable struct Contingency
name::String
lines::Vector{TransmissionLine}
units::Vector{Unit}
thermal_units::Vector{ThermalUnit}
end
mutable struct PriceSensitiveLoad
@ -73,6 +74,13 @@ mutable struct PriceSensitiveLoad
revenue::Vector{Float64}
end
mutable struct ProfiledUnit
name::String
bus::Bus
capacity::Vector{Float64}
cost::Vector{Float64}
end
Base.@kwdef mutable struct UnitCommitmentScenario
buses_by_name::Dict{AbstractString,Bus}
buses::Vector{Bus}
@ -87,11 +95,13 @@ Base.@kwdef mutable struct UnitCommitmentScenario
price_sensitive_loads_by_name::Dict{AbstractString,PriceSensitiveLoad}
price_sensitive_loads::Vector{PriceSensitiveLoad}
probability::Float64
profiled_units_by_name::Dict{AbstractString,ProfiledUnit}
profiled_units::Vector{ProfiledUnit}
reserves_by_name::Dict{AbstractString,Reserve}
reserves::Vector{Reserve}
thermal_units_by_name::Dict{AbstractString,ThermalUnit}
thermal_units::Vector{ThermalUnit}
time::Int
units_by_name::Dict{AbstractString,Unit}
units::Vector{Unit}
end
Base.@kwdef mutable struct UnitCommitmentInstance
@ -103,7 +113,8 @@ function Base.show(io::IO, instance::UnitCommitmentInstance)
sc = instance.scenarios[1]
print(io, "UnitCommitmentInstance(")
print(io, "$(length(instance.scenarios)) scenarios, ")
print(io, "$(length(sc.units)) units, ")
print(io, "$(length(sc.thermal_units)) thermal units, ")
print(io, "$(length(sc.profiled_units)) profiled units, ")
print(io, "$(length(sc.buses)) buses, ")
print(io, "$(length(sc.lines)) lines, ")
print(io, "$(length(sc.contingencies)) contingencies, ")

@ -0,0 +1,212 @@
# 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.
using JuMP
"""
function compute_lmp(
model::JuMP.Model,
method::AELMP;
optimizer,
)::OrderedDict{Tuple{String,Int},Float64}
Calculates the approximate extended locational marginal prices of the given unit commitment instance.
The AELPM does the following three things:
1. It sets the minimum power output of each generator to zero
2. It averages the start-up cost over the offer blocks for each generator
3. It relaxes all integrality constraints
Returns a dictionary mapping `(bus_name, time)` to the marginal price.
WARNING: This approximation method is not fully developed. The implementation is based on MISO Phase I only.
1. It only supports Fast Start resources. More specifically, the minimum up/down time has to be zero.
2. The method does NOT support time-varying start-up costs.
3. An asset is considered offline if it is never on throughout all time periods.
4. The method does NOT support multiple scenarios.
Arguments
---------
- `model`:
the UnitCommitment model, must be solved before calling this function if offline participation is not allowed.
- `method`:
the AELMP method.
- `optimizer`:
the optimizer for solving the LP problem.
Examples
--------
```julia
using UnitCommitment
using HiGHS
import UnitCommitment: AELMP
# Read benchmark instance
instance = UnitCommitment.read_benchmark("matpower/case118/2017-02-01")
# Build the model
model = UnitCommitment.build_model(
instance = instance,
optimizer = HiGHS.Optimizer,
)
# Optimize the model
UnitCommitment.optimize!(model)
# Compute the AELMPs
aelmp = UnitCommitment.compute_lmp(
model,
AELMP(
allow_offline_participation = false,
consider_startup_costs = true
),
optimizer = HiGHS.Optimizer
)
# Access the AELMPs
# Example: "s1" is the scenario name, "b1" is the bus name, 1 is the first time slot
# Note: although scenario is supported, the query still keeps the scenario keys for consistency.
@show aelmp["s1", "b1", 1]
```
"""
function compute_lmp(
model::JuMP.Model,
method::AELMP;
optimizer,
)::OrderedDict{Tuple{String,String,Int},Float64}
@info "Building the approximation model..."
instance = deepcopy(model[:instance])
_aelmp_check_parameters(instance, model, method)
_modify_scenario!(instance.scenarios[1], model, method)
# prepare the result dictionary and solve the model
elmp = OrderedDict()
@info "Solving the approximation model."
approx_model = build_model(instance = instance, variable_names = true)
# relax the binary constraint, and relax integrality
for v in all_variables(approx_model)
if is_binary(v)
unset_binary(v)
end
end
relax_integrality(approx_model)
set_optimizer(approx_model, optimizer)
# solve the model
set_silent(approx_model)
optimize!(approx_model)
# access the dual values
@info "Getting dual values (AELMPs)."
for (key, val) in approx_model[:eq_net_injection]
elmp[key] = dual(val)
end
return elmp
end
function _aelmp_check_parameters(
instance::UnitCommitmentInstance,
model::JuMP.Model,
method::AELMP,
)
# CHECK: model cannot have multiple scenarios
if length(instance.scenarios) > 1
error("The method does NOT support multiple scenarios.")
end
sc = instance.scenarios[1]
# CHECK: model must be solved if allow_offline_participation=false
if !method.allow_offline_participation
if isnothing(model) || !has_values(model)
error(
"A solved UC model is required if allow_offline_participation=false.",
)
end
end
all_units = sc.thermal_units
# CHECK: model cannot handle non-fast-starts (MISO Phase I: can ONLY solve fast-starts)
if any(u -> u.min_uptime > 1 || u.min_downtime > 1, all_units)
error(
"The minimum up/down time of all generators must be 1. AELMP only supports fast-starts.",
)
end
if any(u -> u.initial_power > 0, all_units)
error("The initial power of all generators must be 0.")
end
if any(u -> u.initial_status >= 0, all_units)
error("The initial status of all generators must be negative.")
end
# CHECK: model does not support startup costs (in time series)
if any(u -> length(u.startup_categories) > 1, all_units)
error("The method does NOT support time-varying start-up costs.")
end
end
function _modify_scenario!(
sc::UnitCommitmentScenario,
model::JuMP.Model,
method::AELMP,
)
# this function modifies the sc units (generators)
if !method.allow_offline_participation
# 1. remove (if NOT allowing) the offline generators
units_to_remove = []
for unit in sc.thermal_units
# remove based on the solved UC model result
# remove the unit if it is never on
if all(t -> value(model[:is_on][unit.name, t]) == 0, sc.time)
# unregister from the bus
filter!(x -> x.name != unit.name, unit.bus.thermal_units)
# unregister from the reserve
for r in unit.reserves
filter!(x -> x.name != unit.name, r.thermal_units)
end
# append the name to the remove list
push!(units_to_remove, unit.name)
end
end
# unregister the units from the remove list
filter!(x -> !(x.name in units_to_remove), sc.thermal_units)
end
for unit in sc.thermal_units
# 2. set min generation requirement to 0 by adding 0 to production curve and cost
# min_power & min_costs are vectors with dimension T
if unit.min_power[1] != 0
first_cost_segment = unit.cost_segments[1]
pushfirst!(
unit.cost_segments,
CostSegment(
ones(size(first_cost_segment.mw)) * unit.min_power[1],
ones(size(first_cost_segment.cost)) *
unit.min_power_cost[1] / unit.min_power[1],
),
)
unit.min_power = zeros(size(first_cost_segment.mw))
unit.min_power_cost = zeros(size(first_cost_segment.cost))
end
# 3. average the start-up costs (if considering)
# if consider_startup_costs = false, then use the current first_startup_cost
first_startup_cost = unit.startup_categories[1].cost
if method.consider_startup_costs
additional_unit_cost = first_startup_cost / unit.max_power[1]
for i in eachindex(unit.cost_segments)
unit.cost_segments[i].cost .+= additional_unit_cost
end
first_startup_cost = 0.0 # zero out the start up cost
end
unit.startup_categories =
StartupCategory[StartupCategory(0, first_startup_cost)]
end
return sc.thermal_units_by_name =
Dict(g.name => g for g in sc.thermal_units)
end

@ -0,0 +1,92 @@
# 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.
using JuMP
"""
function compute_lmp(
model::JuMP.Model,
method::ConventionalLMP;
optimizer,
)::OrderedDict{Tuple{String,String,Int},Float64}
Calculates conventional locational marginal prices of the given unit commitment
instance. Returns a dictionary mapping `(bus_name, time)` to the marginal price.
Arguments
---------
- `model`:
the UnitCommitment model, must be solved before calling this function.
- `method`:
the LMP method.
- `optimizer`:
the optimizer for solving the LP problem.
Examples
--------
```julia
using UnitCommitment
using HiGHS
import UnitCommitment: ConventionalLMP
# Read benchmark instance
instance = UnitCommitment.read_benchmark("matpower/case118/2018-01-01")
# Build the model
model = UnitCommitment.build_model(
instance = instance,
optimizer = HiGHS.Optimizer,
)
# Optimize the model
UnitCommitment.optimize!(model)
# Compute the LMPs using the conventional method
lmp = UnitCommitment.compute_lmp(
model,
ConventionalLMP(),
optimizer = HiGHS.Optimizer,
)
# Access the LMPs
# Example: "s1" is the scenario name, "b1" is the bus name, 1 is the first time slot
@show lmp["s1", "b1", 1]
```
"""
function compute_lmp(
model::JuMP.Model,
::ConventionalLMP;
optimizer,
)::OrderedDict{Tuple{String,String,Int},Float64}
if !has_values(model)
error("The UC model must be solved before calculating the LMPs.")
end
lmp = OrderedDict()
@info "Fixing binary variables and relaxing integrality..."
vals = Dict(v => value(v) for v in all_variables(model))
for v in all_variables(model)
if is_binary(v)
unset_binary(v)
fix(v, vals[v])
end
end
relax_integrality(model)
set_optimizer(model, optimizer)
@info "Solving the LP..."
JuMP.optimize!(model)
@info "Getting dual values (LMPs)..."
for (key, val) in model[:eq_net_injection]
lmp[key] = dual(val)
end
return lmp
end

@ -0,0 +1,28 @@
# 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 PricingMethod end
struct ConventionalLMP <: PricingMethod end
"""
struct AELMP <: PricingMethod
allow_offline_participation::Bool = true
consider_startup_costs::Bool = true
end
Approximate Extended LMPs.
Arguments
---------
- `allow_offline_participation`:
If true, offline assets are allowed to participate in pricing.
- `consider_startup_costs`:
If true, the start-up costs are averaged over each unit production; otherwise the production costs stay the same.
"""
Base.@kwdef struct AELMP <: PricingMethod
allow_offline_participation::Bool = true
consider_startup_costs::Bool = true
end

@ -77,7 +77,7 @@ function build_model(;
end
model[:obj] = AffExpr()
model[:instance] = instance
for g in instance.scenarios[1].units
for g in instance.scenarios[1].thermal_units
_add_unit_commitment!(model, g, formulation)
end
for sc in instance.scenarios
@ -93,9 +93,12 @@ function build_model(;
for ps in sc.price_sensitive_loads
_add_price_sensitive_load!(model, ps, sc)
end
for g in sc.units
for g in sc.thermal_units
_add_unit_dispatch!(model, g, formulation, sc)
end
for pu in sc.profiled_units
_add_profiled_unit!(model, pu, sc)
end
_add_system_wide_eqs!(model, sc)
end
@objective(model, Min, model[:obj])

@ -4,7 +4,7 @@
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_ramping::ArrCon2000.Ramping,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_production_piecewise_linear_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_pwl_costs::CarArr2006.PwlCosts,
formulation_status_vars::StatusVarsFormulation,

@ -4,7 +4,7 @@
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_ramping::DamKucRajAta2016.Ramping,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_production_vars!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
sc::UnitCommitmentScenario,
)::Nothing
@ -21,7 +21,7 @@ end
function _add_production_limit_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
sc::UnitCommitmentScenario,
)::Nothing

@ -4,7 +4,7 @@
function _add_production_piecewise_linear_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_pwl_costs::Gar1962.PwlCosts,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_status_vars!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_status_vars::Gar1962.StatusVars,
)::Nothing
is_on = _init(model, :is_on)
@ -27,7 +27,7 @@ end
function _add_status_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_status_vars::Gar1962.StatusVars,
)::Nothing
eq_binary_link = _init(model, :eq_binary_link)

@ -4,7 +4,7 @@
function _add_production_piecewise_linear_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_pwl_costs::KnuOstWat2018.PwlCosts,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_ramping::MorLatRam2013.Ramping,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_startup_cost_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation::MorLatRam2013.StartupCosts,
)::Nothing
eq_startup_choose = _init(model, :eq_startup_choose)

@ -4,7 +4,7 @@
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation_prod_vars::Gar1962.ProdVars,
formulation_ramping::PanGua2016.Ramping,
formulation_status_vars::Gar1962.StatusVars,

@ -4,7 +4,7 @@
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
::Gar1962.ProdVars,
::WanHob2016.Ramping,
::Gar1962.StatusVars,

@ -0,0 +1,32 @@
# 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 _add_profiled_unit!(
model::JuMP.Model,
pu::ProfiledUnit,
sc::UnitCommitmentScenario,
)::Nothing
punits = _init(model, :prod_profiled)
net_injection = _init(model, :expr_net_injection)
for t in 1:model[:instance].time
# Decision variable
punits[sc.name, pu.name, t] =
@variable(model, lower_bound = 0, upper_bound = pu.capacity[t])
# Objective function terms
add_to_expression!(
model[:obj],
punits[sc.name, pu.name, t],
pu.cost[t] * sc.probability,
)
# Net injection
add_to_expression!(
net_injection[sc.name, pu.bus.name, t],
punits[sc.name, pu.name, t],
1.0,
)
end
return
end

@ -53,7 +53,7 @@ function _add_spinning_reserve_eqs!(
model,
sum(
model[:reserve][sc.name, r.name, g.name, t] for
g in r.units
g in r.thermal_units
) + model[:reserve_shortfall][sc.name, r.name, t] >=
r.amount[t]
)
@ -91,7 +91,7 @@ function _add_flexiramp_reserve_eqs!(
model,
sum(
model[:upflexiramp][sc.name, r.name, g.name, t] for
g in r.units
g in r.thermal_units
) + model[:upflexiramp_shortfall][sc.name, r.name, t] >=
r.amount[t]
)
@ -100,7 +100,7 @@ function _add_flexiramp_reserve_eqs!(
model,
sum(
model[:dwflexiramp][sc.name, r.name, g.name, t] for
g in r.units
g in r.thermal_units
) + model[:dwflexiramp_shortfall][sc.name, r.name, t] >=
r.amount[t]
)

@ -6,7 +6,7 @@
# related to the binary commitment, startup and shutdown decisions of units
function _add_unit_commitment!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation::Formulation,
)
if !all(g.must_run) && any(g.must_run)
@ -31,7 +31,7 @@ end
# related to the continuous dispatch decisions of units
function _add_unit_dispatch!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation::Formulation,
sc::UnitCommitmentScenario,
)
@ -64,11 +64,11 @@ function _add_unit_dispatch!(
return
end
_is_initially_on(g::Unit)::Float64 = (g.initial_status > 0 ? 1.0 : 0.0)
_is_initially_on(g::ThermalUnit)::Float64 = (g.initial_status > 0 ? 1.0 : 0.0)
function _add_spinning_reserve_vars!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
sc::UnitCommitmentScenario,
)::Nothing
reserve = _init(model, :reserve)
@ -92,7 +92,7 @@ end
function _add_flexiramp_reserve_vars!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
sc::UnitCommitmentScenario,
)::Nothing
upflexiramp = _init(model, :upflexiramp)
@ -128,7 +128,7 @@ function _add_flexiramp_reserve_vars!(
return
end
function _add_startup_shutdown_vars!(model::JuMP.Model, g::Unit)::Nothing
function _add_startup_shutdown_vars!(model::JuMP.Model, g::ThermalUnit)::Nothing
startup = _init(model, :startup)
for t in 1:model[:instance].time
for s in 1:length(g.startup_categories)
@ -140,7 +140,7 @@ end
function _add_startup_shutdown_limit_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
sc::UnitCommitmentScenario,
)::Nothing
eq_shutdown_limit = _init(model, :eq_shutdown_limit)
@ -179,7 +179,7 @@ end
function _add_ramp_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
formulation::RampingFormulation,
sc::UnitCommitmentScenario,
)::Nothing
@ -224,7 +224,10 @@ function _add_ramp_eqs!(
end
end
function _add_min_uptime_downtime_eqs!(model::JuMP.Model, g::Unit)::Nothing
function _add_min_uptime_downtime_eqs!(
model::JuMP.Model,
g::ThermalUnit,
)::Nothing
is_on = model[:is_on]
switch_off = model[:switch_off]
switch_on = model[:switch_on]
@ -269,7 +272,7 @@ end
function _add_net_injection_eqs!(
model::JuMP.Model,
g::Unit,
g::ThermalUnit,
sc::UnitCommitmentScenario,
)::Nothing
expr_net_injection = model[:expr_net_injection]

@ -10,17 +10,17 @@ solution. Useful for computing LMPs.
"""
function fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
"Production (MW)" keys(solution) ? solution = Dict("s1" => solution) :
nothing
"Thermal production (MW)" keys(solution) ?
solution = Dict("s1" => solution) : nothing
is_on = model[:is_on]
prod_above = model[:prod_above]
reserve = model[:reserve]
for sc in instance.scenarios
for g in sc.units
for g in sc.thermal_units
for t in 1:T
is_on_value = round(solution[sc.name]["Is on"][g.name][t])
prod_value = round(
solution[sc.name]["Production (MW)"][g.name][t],
solution[sc.name]["Thermal production (MW)"][g.name][t],
digits = 5,
)
JuMP.fix(is_on[g.name, t], is_on_value, force = true)
@ -33,7 +33,7 @@ function fix!(model::JuMP.Model, solution::AbstractDict)::Nothing
end
for r in sc.reserves
r.type == "spinning" || continue
for g in r.units
for g in r.thermal_units
for t in 1:T
reserve_value = round(
solution[sc.name]["Spinning reserve (MW)"][r.name][g.name][t],

@ -65,19 +65,26 @@ function solution(model::JuMP.Model)::OrderedDict
sol = OrderedDict()
for sc in instance.scenarios
sol[sc.name] = OrderedDict()
sol[sc.name]["Production (MW)"] =
OrderedDict(g.name => production(g, sc) for g in sc.units)
sol[sc.name]["Production cost (\$)"] =
OrderedDict(g.name => production_cost(g, sc) for g in sc.units)
sol[sc.name]["Startup cost (\$)"] =
OrderedDict(g.name => startup_cost(g, sc) for g in sc.units)
sol[sc.name]["Is on"] = timeseries(model[:is_on], sc.units)
sol[sc.name]["Switch on"] = timeseries(model[:switch_on], sc.units)
sol[sc.name]["Switch off"] = timeseries(model[:switch_off], sc.units)
sol[sc.name]["Net injection (MW)"] =
timeseries(model[:net_injection], sc.buses, sc = sc)
sol[sc.name]["Load curtail (MW)"] =
timeseries(model[:curtail], sc.buses, sc = sc)
if !isempty(sc.thermal_units)
sol[sc.name]["Thermal production (MW)"] = OrderedDict(
g.name => production(g, sc) for g in sc.thermal_units
)
sol[sc.name]["Thermal production cost (\$)"] = OrderedDict(
g.name => production_cost(g, sc) for g in sc.thermal_units
)
sol[sc.name]["Startup cost (\$)"] = OrderedDict(
g.name => startup_cost(g, sc) for g in sc.thermal_units
)
sol[sc.name]["Is on"] = timeseries(model[:is_on], sc.thermal_units)
sol[sc.name]["Switch on"] =
timeseries(model[:switch_on], sc.thermal_units)
sol[sc.name]["Switch off"] =
timeseries(model[:switch_off], sc.thermal_units)
sol[sc.name]["Net injection (MW)"] =
timeseries(model[:net_injection], sc.buses, sc = sc)
sol[sc.name]["Load curtail (MW)"] =
timeseries(model[:curtail], sc.buses, sc = sc)
end
if !isempty(sc.lines)
sol[sc.name]["Line overflow (MW)"] =
timeseries(model[:overflow], sc.lines, sc = sc)
@ -86,11 +93,21 @@ function solution(model::JuMP.Model)::OrderedDict
sol[sc.name]["Price-sensitive loads (MW)"] =
timeseries(model[:loads], sc.price_sensitive_loads, sc = sc)
end
if !isempty(sc.profiled_units)
sol[sc.name]["Profiled production (MW)"] =
timeseries(model[:prod_profiled], sc.profiled_units, sc = sc)
sol[sc.name]["Profiled production cost (\$)"] = OrderedDict(
pu.name => [
value(model[:prod_profiled][sc.name, pu.name, t]) *
pu.cost[t] for t in 1:instance.time
] for pu in sc.profiled_units
)
end
sol[sc.name]["Spinning reserve (MW)"] = OrderedDict(
r.name => OrderedDict(
g.name => [
value(model[:reserve][sc.name, r.name, g.name, t]) for t in 1:instance.time
] for g in r.units
] for g in r.thermal_units
) for r in sc.reserves if r.type == "spinning"
)
sol[sc.name]["Spinning reserve shortfall (MW)"] = OrderedDict(
@ -103,7 +120,7 @@ function solution(model::JuMP.Model)::OrderedDict
r.name => OrderedDict(
g.name => [
value(model[:upflexiramp][sc.name, r.name, g.name, t]) for t in 1:instance.time
] for g in r.units
] for g in r.thermal_units
) for r in sc.reserves if r.type == "flexiramp"
)
sol[sc.name]["Up-flexiramp shortfall (MW)"] = OrderedDict(
@ -115,7 +132,7 @@ function solution(model::JuMP.Model)::OrderedDict
r.name => OrderedDict(
g.name => [
value(model[:dwflexiramp][sc.name, r.name, g.name, t]) for t in 1:instance.time
] for g in r.units
] for g in r.thermal_units
) for r in sc.reserves if r.type == "flexiramp"
)
sol[sc.name]["Down-flexiramp shortfall (MW)"] = OrderedDict(

@ -5,7 +5,7 @@
function set_warm_start!(model::JuMP.Model, solution::AbstractDict)::Nothing
instance, T = model[:instance], model[:instance].time
is_on = model[:is_on]
for g in instance.units
for g in instance.thermal_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(

@ -15,7 +15,7 @@ function generate_initial_conditions!(
sc::UnitCommitmentScenario,
optimizer,
)::Nothing
G = sc.units
G = sc.thermal_units
B = sc.buses
t = 1
mip = JuMP.Model(optimizer)

@ -123,7 +123,7 @@ function _randomize_costs(
sc::UnitCommitmentScenario,
distribution,
)::Nothing
for unit in sc.units
for unit in sc.thermal_units
α = rand(rng, distribution)
unit.min_power_cost *= α
for k in unit.cost_segments
@ -168,7 +168,7 @@ function _randomize_load_profile(
)
push!(system_load, system_load[t-1] * gamma)
end
capacity = sum(maximum(u.max_power) for u in sc.units)
capacity = sum(maximum(u.max_power) for u in sc.thermal_units)
peak_load = rand(rng, params.peak_load) * capacity
system_load = system_load ./ maximum(system_load) .* peak_load

@ -29,7 +29,7 @@ function slice(
for r in sc.reserves
r.amount = r.amount[range]
end
for u in sc.units
for u in sc.thermal_units
u.max_power = u.max_power[range]
u.min_power = u.min_power[range]
u.must_run = u.must_run[range]

@ -15,7 +15,7 @@ Returns the number of validation errors found.
function repair!(sc::UnitCommitmentScenario)::Int
n_errors = 0
for g in sc.units
for g in sc.thermal_units
# Startup costs and delays must be increasing
for s in 2:length(g.startup_categories)

@ -28,8 +28,8 @@ function validate(
instance::UnitCommitmentInstance,
solution::Union{Dict,OrderedDict},
)::Bool
"Production (MW)" keys(solution) ? solution = Dict("s1" => solution) :
nothing
"Thermal production (MW)" keys(solution) ?
solution = Dict("s1" => solution) : nothing
err_count = 0
err_count += _validate_units(instance, solution)
err_count += _validate_reserve_and_demand(instance, solution)
@ -45,8 +45,8 @@ end
function _validate_units(instance::UnitCommitmentInstance, solution; tol = 0.01)
err_count = 0
for sc in instance.scenarios
for unit in sc.units
production = solution[sc.name]["Production (MW)"][unit.name]
for unit in sc.thermal_units
production = solution[sc.name]["Thermal production (MW)"][unit.name]
reserve = [0.0 for _ in 1:instance.time]
spinning_reserves =
[r for r in unit.reserves if r.type == "spinning"]
@ -57,7 +57,7 @@ function _validate_units(instance::UnitCommitmentInstance, solution; tol = 0.01)
)
end
actual_production_cost =
solution[sc.name]["Production cost (\$)"][unit.name]
solution[sc.name]["Thermal production cost (\$)"][unit.name]
actual_startup_cost =
solution[sc.name]["Startup cost (\$)"][unit.name]
is_on = bin(solution[sc.name]["Is on"][unit.name])
@ -114,7 +114,7 @@ function _validate_units(instance::UnitCommitmentInstance, solution; tol = 0.01)
# Verify reserve eligibility
for r in sc.reserves
if r.type == "spinning"
if unit r.units && (
if unit r.thermal_units && (
unit in keys(
solution[sc.name]["Spinning reserve (MW)"][r.name],
)
@ -323,8 +323,8 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
)
end
production = sum(
solution[sc.name]["Production (MW)"][g.name][t] for
g in sc.units
solution[sc.name]["Thermal production (MW)"][g.name][t] for
g in sc.thermal_units
)
if "Load curtail (MW)" in keys(solution)
load_curtail = sum(
@ -352,7 +352,7 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
if r.type == "spinning"
provided = sum(
solution[sc.name]["Spinning reserve (MW)"][r.name][g.name][t]
for g in r.units
for g in r.thermal_units
)
shortfall =
solution[sc.name]["Spinning reserve shortfall (MW)"][r.name][t]
@ -371,7 +371,7 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
elseif r.type == "flexiramp"
upflexiramp = sum(
solution[sc.name]["Up-flexiramp (MW)"][r.name][g.name][t]
for g in r.units
for g in r.thermal_units
)
upflexiramp_shortfall =
solution[sc.name]["Up-flexiramp shortfall (MW)"][r.name][t]
@ -389,7 +389,7 @@ function _validate_reserve_and_demand(instance, solution, tol = 0.01)
dwflexiramp = sum(
solution[sc.name]["Down-flexiramp (MW)"][r.name][g.name][t]
for g in r.units
for g in r.thermal_units
)
dwflexiramp_shortfall =
solution[sc.name]["Down-flexiramp shortfall (MW)"][r.name][t]

@ -3,6 +3,7 @@ Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76"
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f"
GZip = "92fee26a-97fe-5a0c-ad85-20a5f3185b63"
HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b"
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -7,12 +7,13 @@ using UnitCommitment
@testset "read_egret_solution" begin
solution =
UnitCommitment.read_egret_solution("$FIXTURES/egret_output.json.gz")
for attr in ["Is on", "Production (MW)", "Production cost (\$)"]
for attr in
["Is on", "Thermal production (MW)", "Thermal production cost (\$)"]
@test attr in keys(solution)
@test "115_STEAM_1" in keys(solution[attr])
@test length(solution[attr]["115_STEAM_1"]) == 48
end
@test solution["Production cost (\$)"]["315_CT_6"][15:20] ==
@test solution["Thermal production cost (\$)"]["315_CT_6"][15:20] ==
[0.0, 0.0, 884.44, 1470.71, 1470.71, 884.44]
@test solution["Startup cost (\$)"]["315_CT_6"][15:20] ==
[0.0, 0.0, 5665.23, 0.0, 0.0, 0.0]

@ -9,14 +9,14 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test length(instance.scenarios) == 1
sc = instance.scenarios[1]
@test length(sc.reserves_by_name["r1"].amount) == 4
@test sc.units_by_name["g2"].reserves[1].name == "r1"
@test sc.thermal_units_by_name["g2"].reserves[1].name == "r1"
end
@testset "read v0.3" begin
instance = UnitCommitment.read("$FIXTURES/ucjl-0.3.json.gz")
@test length(instance.scenarios) == 1
sc = instance.scenarios[1]
@test length(sc.units) == 6
@test length(sc.thermal_units) == 6
@test length(sc.buses) == 14
@test length(sc.lines) == 20
end

@ -8,7 +8,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
instance = UnitCommitment.read("$FIXTURES/case14.json.gz")
@test repr(instance) == (
"UnitCommitmentInstance(1 scenarios, 6 units, 14 buses, " *
"UnitCommitmentInstance(1 scenarios, 6 thermal units, 0 profiled units, 14 buses, " *
"20 lines, 19 contingencies, 1 price sensitive loads, 4 time steps)"
)
@ -16,7 +16,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
sc = instance.scenarios[1]
@test length(sc.lines) == 20
@test length(sc.buses) == 14
@test length(sc.units) == 6
@test length(sc.thermal_units) == 6
@test length(sc.contingencies) == 19
@test length(sc.price_sensitive_loads) == 1
@test instance.time == 4
@ -49,7 +49,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test sc.reserves[1].amount == [100.0, 100.0, 100.0, 100.0]
@test sc.reserves_by_name["r1"].name == "r1"
unit = sc.units[1]
unit = sc.thermal_units[1]
@test unit.name == "g1"
@test unit.bus.name == "b1"
@test unit.ramp_up_limit == 1e6
@ -76,14 +76,14 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test unit.startup_categories[2].cost == 1500.0
@test unit.startup_categories[3].cost == 2000.0
@test length(unit.reserves) == 0
@test sc.units_by_name["g1"].name == "g1"
@test sc.thermal_units_by_name["g1"].name == "g1"
unit = sc.units[2]
unit = sc.thermal_units[2]
@test unit.name == "g2"
@test unit.must_run == [false for t in 1:4]
@test length(unit.reserves) == 1
unit = sc.units[3]
unit = sc.thermal_units[3]
@test unit.name == "g3"
@test unit.bus.name == "b3"
@test unit.ramp_up_limit == 70.0
@ -106,7 +106,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test unit.reserves[1].name == "r1"
@test sc.contingencies[1].lines == [sc.lines[1]]
@test sc.contingencies[1].units == []
@test sc.contingencies[1].thermal_units == []
@test sc.contingencies[1].name == "c1"
@test sc.contingencies_by_name["c1"].name == "c1"
@ -121,7 +121,7 @@ end
@testset "read_benchmark sub-hourly" begin
instance = UnitCommitment.read("$FIXTURES/case14-sub-hourly.json.gz")
@test instance.time == 4
unit = instance.scenarios[1].units[1]
unit = instance.scenarios[1].thermal_units[1]
@test unit.name == "g1"
@test unit.min_uptime == 2
@test unit.min_downtime == 2
@ -131,3 +131,23 @@ end
@test unit.startup_categories[3].delay == 6
@test unit.initial_status == -200
end
@testset "read_benchmark profiled-units" begin
instance = UnitCommitment.read("$FIXTURES/case14-profiled.json.gz")
sc = instance.scenarios[1]
@test length(sc.profiled_units) == 2
first_pu = sc.profiled_units[1]
@test first_pu.name == "g7"
@test first_pu.bus.name == "b4"
@test first_pu.cost == [100.0 for t in 1:4]
@test first_pu.capacity == [100.0 for t in 1:4]
@test sc.profiled_units_by_name["g7"].name == "g7"
second_pu = sc.profiled_units[2]
@test second_pu.name == "g8"
@test second_pu.bus.name == "b5"
@test second_pu.cost == [50.0 for t in 1:4]
@test second_pu.capacity == [120.0 for t in 1:4]
@test sc.profiled_units_by_name["g8"].name == "g8"
end

@ -0,0 +1,35 @@
# 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.
using UnitCommitment, Cbc, HiGHS, JuMP
import UnitCommitment: AELMP
@testset "aelmp" begin
path = "$FIXTURES/aelmp_simple.json.gz"
# model has to be solved first
instance = UnitCommitment.read(path)
model = UnitCommitment.build_model(
instance = instance,
optimizer = Cbc.Optimizer,
variable_names = true,
)
JuMP.set_silent(model)
UnitCommitment.optimize!(model)
# policy 1: allow offlines; consider startups
aelmp_1 =
UnitCommitment.compute_lmp(model, AELMP(), optimizer = HiGHS.Optimizer)
@test aelmp_1["s1", "B1", 1] 231.7 atol = 0.1
# policy 2: do not allow offlines; but consider startups
aelmp_2 = UnitCommitment.compute_lmp(
model,
AELMP(
allow_offline_participation = false,
consider_startup_costs = true,
),
optimizer = HiGHS.Optimizer,
)
@test aelmp_2["s1", "B1", 1] 274.3 atol = 0.1
end

@ -0,0 +1,51 @@
# 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.
using UnitCommitment, Cbc, HiGHS, JuMP
import UnitCommitment: ConventionalLMP
function solve_conventional_testcase(path::String)
instance = UnitCommitment.read(path)
model = UnitCommitment.build_model(
instance = instance,
optimizer = Cbc.Optimizer,
variable_names = true,
)
JuMP.set_silent(model)
UnitCommitment.optimize!(model)
lmp = UnitCommitment.compute_lmp(
model,
ConventionalLMP(),
optimizer = HiGHS.Optimizer,
)
return lmp
end
@testset "conventional" begin
# instance 1
path = "$FIXTURES/lmp_simple_test_1.json.gz"
lmp = solve_conventional_testcase(path)
@test lmp["s1", "A", 1] == 50.0
@test lmp["s1", "B", 1] == 50.0
# instance 2
path = "$FIXTURES/lmp_simple_test_2.json.gz"
lmp = solve_conventional_testcase(path)
@test lmp["s1", "A", 1] == 50.0
@test lmp["s1", "B", 1] == 60.0
# instance 3
path = "$FIXTURES/lmp_simple_test_3.json.gz"
lmp = solve_conventional_testcase(path)
@test lmp["s1", "A", 1] == 50.0
@test lmp["s1", "B", 1] == 70.0
@test lmp["s1", "C", 1] == 100.0
# instance 4
path = "$FIXTURES/lmp_simple_test_4.json.gz"
lmp = solve_conventional_testcase(path)
@test lmp["s1", "A", 1] == 50.0
@test lmp["s1", "B", 1] == 70.0
@test lmp["s1", "C", 1] == 90.0
end

@ -39,4 +39,8 @@ FIXTURES = "$(@__DIR__)/fixtures"
@testset "validation" begin
include("validation/repair_test.jl")
end
@testset "lmp" begin
include("lmp/conventional_test.jl")
include("lmp/aelmp_test.jl")
end
end

@ -10,7 +10,7 @@ using UnitCommitment, Cbc, JuMP
optimizer = optimizer_with_attributes(Cbc.Optimizer, "logLevel" => 0)
sc = instance.scenarios[1]
# All units should have unknown initial conditions
for g in sc.units
for g in sc.thermal_units
@test g.initial_power === nothing
@test g.initial_status === nothing
end
@ -19,7 +19,7 @@ using UnitCommitment, Cbc, JuMP
UnitCommitment.generate_initial_conditions!(sc, optimizer)
# All units should now have known initial conditions
for g in sc.units
for g in sc.thermal_units
@test g.initial_power !== nothing
@test g.initial_status !== nothing
end

@ -21,7 +21,7 @@ test_approx(x, y) = @test isapprox(x, y, atol = 1e-3)
@testset "cost and load share" begin
sc = get_scenario()
# Check original costs
unit = sc.units[10]
unit = sc.thermal_units[10]
test_approx(unit.min_power_cost[1], 825.023)
test_approx(unit.cost_segments[1].cost[1], 36.659)
test_approx(unit.startup_categories[1].cost[1], 7570.42)

@ -13,7 +13,7 @@ using UnitCommitment, LinearAlgebra, Cbc, JuMP, JSON, GZip
@test modified.time == 2
@test length(sc.power_balance_penalty) == 2
@test length(sc.reserves_by_name["r1"].amount) == 2
for u in sc.units
for u in sc.thermal_units
@test length(u.max_power) == 2
@test length(u.min_power) == 2
@test length(u.must_run) == 2

Loading…
Cancel
Save