diff --git a/src/UnitCommitment.jl b/src/UnitCommitment.jl index 484e5be..a2886fc 100644 --- a/src/UnitCommitment.jl +++ b/src/UnitCommitment.jl @@ -4,6 +4,8 @@ module UnitCommitment +@info "PU" ##REMOVE ME + include("instance/structs.jl") include("model/formulations/base/structs.jl") include("solution/structs.jl") @@ -30,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") diff --git a/src/instance/migrate.jl b/src/instance/migrate.jl index becb912..317a309 100644 --- a/src/instance/migrate.jl +++ b/src/instance/migrate.jl @@ -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 diff --git a/src/instance/read.jl b/src/instance/read.jl index 1586e63..98393e7 100644 --- a/src/instance/read.jl +++ b/src/instance/read.jl @@ -85,6 +85,7 @@ function _from_json(json; repair = true) lines = TransmissionLine[] loads = PriceSensitiveLoad[] reserves = Reserve[] + profiled_units = ProfiledUnit[] function scalar(x; default = nothing) x !== nothing || return default @@ -135,6 +136,7 @@ function _from_json(json; repair = true) length(buses), timeseries(dict["Load (MW)"]), Unit[], + ProfiledUnit[], PriceSensitiveLoad[], ) name_to_bus[bus_name] = bus @@ -161,90 +163,107 @@ function _from_json(json; repair = true) # 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 = 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) + end + name_to_unit[unit_name] = unit + push!(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 @@ -325,6 +344,8 @@ function _from_json(json; repair = true) time = T, units_by_name = Dict(g.name => g for g in units), units = units, + profiled_units_by_name = Dict(pu.name => pu for pu in profiled_units), + profiled_units = profiled_units, ) if repair UnitCommitment.repair!(instance) diff --git a/src/instance/structs.jl b/src/instance/structs.jl index f07f694..3d74524 100644 --- a/src/instance/structs.jl +++ b/src/instance/structs.jl @@ -7,6 +7,7 @@ mutable struct Bus offset::Int load::Vector{Float64} units::Vector + profiled_units::Vector price_sensitive_loads::Vector end @@ -48,6 +49,13 @@ mutable struct Unit reserves::Vector{Reserve} end +mutable struct ProfiledUnit + name::String + bus::Bus + capacity::Vector{Float64} + cost::Vector{Float64} +end + mutable struct TransmissionLine name::String offset::Int @@ -90,11 +98,14 @@ Base.@kwdef mutable struct UnitCommitmentInstance time::Int units_by_name::Dict{AbstractString,Unit} units::Vector{Unit} + profiled_units_by_name::Dict{AbstractString,ProfiledUnit} + profiled_units::Vector{ProfiledUnit} end function Base.show(io::IO, instance::UnitCommitmentInstance) print(io, "UnitCommitmentInstance(") print(io, "$(length(instance.units)) units, ") + print(io, "$(length(instance.profiled_units)) profiled units, ") print(io, "$(length(instance.buses)) buses, ") print(io, "$(length(instance.lines)) lines, ") print(io, "$(length(instance.contingencies)) contingencies, ") diff --git a/src/model/build.jl b/src/model/build.jl index 8ee7235..090aad9 100644 --- a/src/model/build.jl +++ b/src/model/build.jl @@ -90,6 +90,9 @@ function build_model(; for ps in instance.price_sensitive_loads _add_price_sensitive_load!(model, ps) end + for pu in instance.profiled_units + _add_profiled_unit!(model, pu) + end _add_system_wide_eqs!(model) @objective(model, Min, model[:obj]) end diff --git a/src/model/formulations/base/punit.jl b/src/model/formulations/base/punit.jl new file mode 100644 index 0000000..81e86cc --- /dev/null +++ b/src/model/formulations/base/punit.jl @@ -0,0 +1,27 @@ +# 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, +)::Nothing + punits = _init(model, :profiled_units) + net_injection = _init(model, :expr_net_injection) + for t in 1:model[:instance].time + # Decision variable + punits[pu.name, t] = + @variable(model, lower_bound = 0, upper_bound = pu.capacity[t]) + + # Objective function terms + add_to_expression!(model[:obj], punits[pu.name, t], pu.cost[t]) + + # Net injection + add_to_expression!( + net_injection[pu.bus.name, t], + punits[pu.name, t], + 1.0, + ) + end + return +end diff --git a/test/fixtures/case14-profiled.json.gz b/test/fixtures/case14-profiled.json.gz new file mode 100644 index 0000000..c98d7d5 Binary files /dev/null and b/test/fixtures/case14-profiled.json.gz differ diff --git a/test/fixtures/ucjl-0.4.json.gz b/test/fixtures/ucjl-0.4.json.gz new file mode 100644 index 0000000..0721e54 Binary files /dev/null and b/test/fixtures/ucjl-0.4.json.gz differ diff --git a/test/instance/migrate_test.jl b/test/instance/migrate_test.jl index 2ea6d46..33889a1 100644 --- a/test/instance/migrate_test.jl +++ b/test/instance/migrate_test.jl @@ -16,3 +16,11 @@ end @test length(instance.buses) == 14 @test length(instance.lines) == 20 end + +@testset "read v0.4" begin + instance = UnitCommitment.read("$FIXTURES/ucjl-0.4.json.gz") + @test length(instance.units) == 6 + @test length(instance.buses) == 14 + @test length(instance.lines) == 20 + @test length(instance.profiled_units) == 2 +end diff --git a/test/instance/read_test.jl b/test/instance/read_test.jl index cc9e332..0ef0cc7 100644 --- a/test/instance/read_test.jl +++ b/test/instance/read_test.jl @@ -124,3 +124,117 @@ 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") + @test length(instance.lines) == 20 + @test length(instance.buses) == 14 + @test length(instance.units) == 6 + @test length(instance.profiled_units) == 2 + @test length(instance.contingencies) == 19 + @test length(instance.price_sensitive_loads) == 1 + @test instance.time == 4 + + @test instance.lines[5].name == "l5" + @test instance.lines[5].source.name == "b2" + @test instance.lines[5].target.name == "b5" + @test instance.lines[5].reactance ≈ 0.17388 + @test instance.lines[5].susceptance ≈ 10.037550333 + @test instance.lines[5].normal_flow_limit == [1e8 for t in 1:4] + @test instance.lines[5].emergency_flow_limit == [1e8 for t in 1:4] + @test instance.lines[5].flow_limit_penalty == [5e3 for t in 1:4] + @test instance.lines_by_name["l5"].name == "l5" + + @test instance.lines[1].name == "l1" + @test instance.lines[1].source.name == "b1" + @test instance.lines[1].target.name == "b2" + @test instance.lines[1].reactance ≈ 0.059170 + @test instance.lines[1].susceptance ≈ 29.496860773945 + @test instance.lines[1].normal_flow_limit == [300.0 for t in 1:4] + @test instance.lines[1].emergency_flow_limit == [400.0 for t in 1:4] + @test instance.lines[1].flow_limit_penalty == [1e3 for t in 1:4] + + @test instance.buses[9].name == "b9" + @test instance.buses[9].load == [35.36638, 33.25495, 31.67138, 31.14353] + @test instance.buses_by_name["b9"].name == "b9" + + @test instance.reserves[1].name == "r1" + @test instance.reserves[1].type == "spinning" + @test instance.reserves[1].amount == [100.0, 100.0, 100.0, 100.0] + @test instance.reserves_by_name["r1"].name == "r1" + + unit = instance.units[1] + @test unit.name == "g1" + @test unit.bus.name == "b1" + @test unit.ramp_up_limit == 1e6 + @test unit.ramp_down_limit == 1e6 + @test unit.startup_limit == 1e6 + @test unit.shutdown_limit == 1e6 + @test unit.must_run == [false for t in 1:4] + @test unit.min_power_cost == [1400.0 for t in 1:4] + @test unit.min_uptime == 1 + @test unit.min_downtime == 1 + for t in 1:1 + @test unit.cost_segments[1].mw[t] == 10.0 + @test unit.cost_segments[2].mw[t] == 20.0 + @test unit.cost_segments[3].mw[t] == 5.0 + @test unit.cost_segments[1].cost[t] ≈ 20.0 + @test unit.cost_segments[2].cost[t] ≈ 30.0 + @test unit.cost_segments[3].cost[t] ≈ 40.0 + end + @test length(unit.startup_categories) == 3 + @test unit.startup_categories[1].delay == 1 + @test unit.startup_categories[2].delay == 2 + @test unit.startup_categories[3].delay == 3 + @test unit.startup_categories[1].cost == 1000.0 + @test unit.startup_categories[2].cost == 1500.0 + @test unit.startup_categories[3].cost == 2000.0 + @test length(unit.reserves) == 0 + @test instance.units_by_name["g1"].name == "g1" + + unit = instance.units[2] + @test unit.name == "g2" + @test unit.must_run == [false for t in 1:4] + @test length(unit.reserves) == 1 + + unit = instance.units[3] + @test unit.name == "g3" + @test unit.bus.name == "b3" + @test unit.ramp_up_limit == 70.0 + @test unit.ramp_down_limit == 70.0 + @test unit.startup_limit == 70.0 + @test unit.shutdown_limit == 70.0 + @test unit.must_run == [true for t in 1:4] + @test unit.min_power_cost == [0.0 for t in 1:4] + @test unit.min_uptime == 1 + @test unit.min_downtime == 1 + for t in 1:4 + @test unit.cost_segments[1].mw[t] ≈ 33 + @test unit.cost_segments[2].mw[t] ≈ 33 + @test unit.cost_segments[3].mw[t] ≈ 34 + @test unit.cost_segments[1].cost[t] ≈ 33.75 + @test unit.cost_segments[2].cost[t] ≈ 38.04 + @test unit.cost_segments[3].cost[t] ≈ 44.77853 + end + @test length(unit.reserves) == 1 + @test unit.reserves[1].name == "r1" + + @test instance.contingencies[1].lines == [instance.lines[1]] + @test instance.contingencies[1].units == [] + @test instance.contingencies[1].name == "c1" + @test instance.contingencies_by_name["c1"].name == "c1" + + load = instance.price_sensitive_loads[1] + @test load.name == "ps1" + @test load.bus.name == "b3" + @test load.revenue == [100.0 for t in 1:4] + @test load.demand == [50.0 for t in 1:4] + @test instance.price_sensitive_loads_by_name["ps1"].name == "ps1" + + profiled_unit = instance.profiled_units[2] + @test profiled_unit.name == "g8" + @test profiled_unit.bus.name == "b5" + @test profiled_unit.cost == [50.0 for t in 1:4] + @test profiled_unit.capacity == [120.0 for t in 1:4] + @test instance.profiled_units_by_name["g8"].name == "g8" +end \ No newline at end of file