From 6ddd3d6c00e71a21d02b1eb55053de04a2478351 Mon Sep 17 00:00:00 2001 From: Jun He Date: Wed, 10 Aug 2022 16:00:27 -0400 Subject: [PATCH] built lmp and aelmp functions and testcases --- Project.toml | 1 + src/UnitCommitment.jl | 2 + src/utils/aelmp.jl | 215 +++++++++++++++++++++++++++++++++++++++++ src/utils/lmp.jl | 121 +++++++++++++++++++++++ test/Project.toml | 1 + test/lmp/aelmp_test.jl | 30 ++++++ test/lmp/lmp_test.jl | 52 ++++++++++ test/runtests.jl | 4 + 8 files changed, 426 insertions(+) create mode 100644 src/utils/aelmp.jl create mode 100644 src/utils/lmp.jl create mode 100644 test/lmp/aelmp_test.jl create mode 100644 test/lmp/lmp_test.jl diff --git a/Project.toml b/Project.toml index 9c3832e..d60285b 100644 --- a/Project.toml +++ b/Project.toml @@ -5,6 +5,7 @@ repo = "https://github.com/ANL-CEEESA/UnitCommitment.jl" version = "0.3.0" [deps] +Clp = "e2554f3b-3117-50c0-817c-e040a3ddf72d" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" diff --git a/src/UnitCommitment.jl b/src/UnitCommitment.jl index 57e795b..6099659 100644 --- a/src/UnitCommitment.jl +++ b/src/UnitCommitment.jl @@ -54,6 +54,8 @@ include("transform/slice.jl") include("transform/randomize/XavQiuAhm2021.jl") include("utils/log.jl") include("utils/benchmark.jl") +include("utils/lmp.jl") +include("utils/aelmp.jl") include("validation/repair.jl") include("validation/validate.jl") diff --git a/src/utils/aelmp.jl b/src/utils/aelmp.jl new file mode 100644 index 0000000..af3192d --- /dev/null +++ b/src/utils/aelmp.jl @@ -0,0 +1,215 @@ +# 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 +using Clp + +""" + function get_aelmp( + path::String; + optimizer = nothing, + solved_uc_model::Union{JuMP.Model, Nothing} = nothing, + allow_offline_participation::Bool = true, + consider_startup_costs::Bool = true + ) + +Calculates the approximate extended locational marginal prices of the given unit commitment instance. +The AELPM does the following three things: +1. It removes the minimum generation requirement for each generator +2. It averages the start-up cost over the offer blocks for each generator +3. It relaxes all the binary constraints and integrality +Returns a dictionary of AELMPs. Each key is usually a tuple of "Bus name" and time index. + +NOTE: 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 series of start-up costs. +3. The method can only calculate for the first time slot if allow_offline_participation=false. + +Arguments +--------- + +- `path`: + the file path of the input data. + +- `optimizer`: + the optimizer for solving the problem. If not specified, the method will use Clp. + +- `solved_uc_model`: + the original unit commitment model that has been solved. This is used ONLY with allow_offline_participation + being set to false. + +- `allow_offline_participation`: + defaults to true. If true, offline assets are allowed to participate in pricing; otherwise those + assets are NOT allowed to participate. + +- `consider_startup_costs`: + defaults to true. If true, the start-up costs are averaged over each unit production; otherwise the + production costs stay the same. + +Examples +-------- + +```julia + +using UnitCommitment +using Clp + +# Get the AELMPs with the file path (default policy) +aelmp = UnitCommitment.get_aelmp( + "example.json", + optimizer = Clp.Optimizer, +) + +# Get the AELMPs with an alternative policy +# Do not allow offline generators for price participation +# solve the UC model first +instance = UnitCommitment.read("example.json") +model = UnitCommitment.build_model( + instance=instance, + optimizer=Clp.Optimizer, + variable_names = true, +) +UnitCommitment.optimize!(model) +# then call the AELMP method +aelmp = UnitCommitment.get_aelmp( + "example.json", + solved_uc_model = model, + allow_offline_participation = false, +) + +# Accessing the 'aelmp' dictionary +# Example: "b1" is the bus name, 1 is the first time slot +@show aelmp["b1", 1] + +``` + +""" + +function get_aelmp( + path::String; + optimizer = nothing, + solved_uc_model::Union{JuMP.Model, Nothing} = nothing, + allow_offline_participation::Bool = true, + consider_startup_costs::Bool = true +) + @info "Calculating the AELMP..." + @info "Building the approximation model..." + # get the json object from file path. + json = _read_json(path) + + # if optimizer is not specified, use Clp + if isnothing(optimizer) + optimizer = Clp.Optimizer + end + + # CHECK: model must be solved if allow_offline_participation=false + if allow_offline_participation # do nothing + @info "Offline generators are allowed to participate in pricing." + else + if isnothing(solved_uc_model) + @warn "No UC model is detected. A solved UC model is required if allow_offline_participation == false." + @warn "Setting parameter allow_offline_participation = true" + allow_offline_participation = true # and do nothing else + elseif !has_values(solved_uc_model) + @warn "The UC model has no solution. A solved UC model is required if allow_offline_participation == false." + @warn "Setting parameter allow_offline_participation = true" + allow_offline_participation = true # and do nothing else + else + # the inputs are correct + @info "Offline generators are NOT allowed to participate in pricing." + @info "Offline generators will be removed for the approximation." + end + end + + # CHECK: start up cost consideration + if consider_startup_costs + @info "Startup costs are considered." + else + @info "Startup costs are NOT considered." + end + + # modify the data for each generator + for (unit_name, dict) in json["Generators"] + # 1. remove (if NOT allowing) the offline generators + if !allow_offline_participation + # remove based on the solved UC model result + # here, only look at the first time slot (TIME-SERIES-NOT-SUPPORTED) + is_on = value(solved_uc_model[:is_on][unit_name, 1]) + if is_on == 0 + delete!(json["Generators"], unit_name) + continue + end + end + + # 2. set min generation requirement to 0 by adding 0 to production curve and cost + cost_curve_mw = dict["Production cost curve (MW)"] + cost_curve_dollar = dict["Production cost curve (\$)"] + if cost_curve_mw[1] != 0 + pushfirst!(cost_curve_mw, 0) + pushfirst!(cost_curve_dollar, 0) + dict["Production cost curve (MW)"] = cost_curve_mw + dict["Production cost curve (\$)"] = cost_curve_dollar + end + + # 3. average the start-up costs (if considering) + # for now, consider first element only (TIME-SERIES-NOT-SUPPORTED) + first_startup_cost = dict["Startup costs (\$)"][1] + if consider_startup_costs + additional_unit_cost = first_startup_cost / cost_curve_mw[end] + for i in eachindex(cost_curve_dollar) + cost_curve_dollar[i] += additional_unit_cost * cost_curve_mw[i] + end + dict["Production cost curve (\$)"] = cost_curve_dollar + dict["Startup costs (\$)"] = [0.0] + else + # or do nothing (just keep the first cost) + dict["Startup costs (\$)"] = [first_startup_cost] + end + + # 4. other adjustments... + ### FIXME in the future + # MISO Phase I: can ONLY solve fast-starts + # here, force all startup time to be 0 + dict["Startup delays (h)"] = [0] + dict["Initial status (h)"] = -100 + dict["Initial power (MW)"] = 0 + dict["Minimum uptime (h)"] = 0 + dict["Minimum downtime (h)"] = 0 + ### END FIXME + + # update + json["Generators"][unit_name] = dict + end + + # prepare the result dictionary and solve the model + elmp = Dict() + + # init the model + @info "Solving the approximation model." + instance = _from_json(json) # obtain the instance object + model = build_model( + instance=instance, + variable_names=true, + ) + + # relax the binary constraint, and relax integrality + for v in all_variables(model) + if is_binary(v) + unset_binary(v) + end + end + relax_integrality(model) + set_optimizer(model, optimizer) + + # solve the model + set_silent(model) + optimize!(model) + + # access the dual values + @info "Getting dual values (AELMPs)." + for (key, val) in model[:eq_net_injection] + elmp[key] = dual(val) + end + return elmp +end \ No newline at end of file diff --git a/src/utils/lmp.jl b/src/utils/lmp.jl new file mode 100644 index 0000000..1eaa546 --- /dev/null +++ b/src/utils/lmp.jl @@ -0,0 +1,121 @@ +# 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, Clp + +""" + function get_lmp( + model::JuMP.Model; + optimizer = nothing, + verbose::Bool=true + ) + +Calculates the locational marginal prices of the given unit commitment instance. +Returns a dictionary of LMPs. Each key is usually a tuple of "Bus name" and time index. +Returns false if there is an error in solving the LMPs. + +Arguments +--------- + +- `model`: + the UnitCommitment model, must be solved before calling this function. + +- `optimizer`: + the optimizer for solving the LP problem. If not specified, the method will use Clp. + +- `verbose`: + defaults to true. If false, all error/info messages will be suppressed. + +Examples +-------- + +```julia +# Read benchmark instance +instance = UnitCommitment.read_benchmark("matpower/case118/2017-02-01") + +# Construct model (using state-of-the-art defaults) +model = UnitCommitment.build_model( + instance = instance, + optimizer = Cbc.Optimizer, +) + +# Get the LMPs before solving the UC model +# Error messages will be displayed and the returned value is false. +# lmp = UnitCommitment.get_lmp(model) + +UnitCommitment.optimize!(model) + +# Get the LMPs after solving the UC model (the correct way) +# DO NOT use Cbc as the optimizer here. Cbc does not support dual values. +lmp = UnitCommitment.get_lmp( + model, + optimizer=Clp.Optimizer +) + +# Accessing the 'lmp' dictionary +# Example: "b1" is the bus name, 1 is the first time slot +@show lmp["b1", 1] + +``` + +""" + +function get_lmp( + model::JuMP.Model; + optimizer = nothing, + verbose::Bool=true +) + # Validate model, the UC model must be solved beforehand + if !has_values(model) + if verbose + @error "The UC model must be solved before calculating the LMPs." + @error "The LMPs are NOT calculated." + end + return false + end + + # if optimizer is not specified, use Clp + if isnothing(optimizer) + optimizer = Clp.Optimizer + end + + # Prepare the LMP result dictionary + lmp = Dict() + + # Calculate LMPs + # Fix all binary variables to their optimal values and relax integrality + if verbose + @info "Calculating LMPs..." + @info "Fixing all binary variables to their optimal values and relax integrality." + end + 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) + + # Solve the LP + if verbose + @info "Solving the LP." + end + JuMP.optimize!(model) + + # Obtain dual values (LMPs) and store into the LMP dictionary + if verbose + @info "Getting dual values (LMPs)." + end + for (key, val) in model[:eq_net_injection] + lmp[key] = dual(val) + end + + # Return the LMP dictionary + if verbose + @info "Calculation completed." + end + return lmp +end \ No newline at end of file diff --git a/test/Project.toml b/test/Project.toml index b87293d..6794bfd 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,5 +1,6 @@ [deps] Cbc = "9961bab8-2fa3-5c5a-9d89-47fab24efd76" +Clp = "e2554f3b-3117-50c0-817c-e040a3ddf72d" DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" Distributions = "31c24e10-a181-5473-b8eb-7969acd0382f" GZip = "92fee26a-97fe-5a0c-ad85-20a5f3185b63" diff --git a/test/lmp/aelmp_test.jl b/test/lmp/aelmp_test.jl new file mode 100644 index 0000000..d992658 --- /dev/null +++ b/test/lmp/aelmp_test.jl @@ -0,0 +1,30 @@ +# 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, JuMP + +@testset "aelmp" begin + path = "$FIXTURES/aelmp_simple.json.gz" + + # policy 1: allow offlines; consider startups + aelmp_1 = UnitCommitment.get_aelmp(path) + @test aelmp_1["B1", 1] ≈ 231.7 atol = 0.1 + + # policy 2: do not allow offlines; but consider startups + # 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) + aelmp_2 = UnitCommitment.get_aelmp( + path, + solved_uc_model = model, + allow_offline_participation = false, + ) + @test aelmp_2["B1", 1] ≈ 274.3 atol = 0.1 +end \ No newline at end of file diff --git a/test/lmp/lmp_test.jl b/test/lmp/lmp_test.jl new file mode 100644 index 0000000..4b72daf --- /dev/null +++ b/test/lmp/lmp_test.jl @@ -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. + +using UnitCommitment, Cbc, Clp, JuMP + +function solve_lmp_testcase(path::String) + instance = UnitCommitment.read(path) + model = UnitCommitment.build_model( + instance = instance, + optimizer = Cbc.Optimizer, + variable_names = true, + ) + # set silent, solve the UC + JuMP.set_silent(model) + UnitCommitment.optimize!(model) + # get the lmp + lmp = UnitCommitment.get_lmp( + model, + optimizer=Clp.Optimizer, + verbose=false + ) + return lmp +end + +@testset "lmp" begin + # instance 1 + path = "$FIXTURES/lmp_simple_test_1.json.gz" + lmp = solve_lmp_testcase(path) + @test lmp["A", 1] == 50.0 + @test lmp["B", 1] == 50.0 + + # instance 2 + path = "$FIXTURES/lmp_simple_test_2.json.gz" + lmp = solve_lmp_testcase(path) + @test lmp["A", 1] == 50.0 + @test lmp["B", 1] == 60.0 + + # instance 3 + path = "$FIXTURES/lmp_simple_test_3.json.gz" + lmp = solve_lmp_testcase(path) + @test lmp["A", 1] == 50.0 + @test lmp["B", 1] == 70.0 + @test lmp["C", 1] == 100.0 + + # instance 4 + path = "$FIXTURES/lmp_simple_test_4.json.gz" + lmp = solve_lmp_testcase(path) + @test lmp["A", 1] == 50.0 + @test lmp["B", 1] == 70.0 + @test lmp["C", 1] == 90.0 +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index 9a61bf2..4203495 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -39,4 +39,8 @@ FIXTURES = "$(@__DIR__)/fixtures" @testset "validation" begin include("validation/repair_test.jl") end + @testset "lmp" begin + include("lmp/lmp_test.jl") + include("lmp/aelmp_test.jl") + end end