diff --git a/README.md b/README.md index ddbadcc..8b74be1 100755 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/Project.toml b/docs/Project.toml index f9afcb4..01a33ce 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -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" diff --git a/docs/make.jl b/docs/make.jl index d7a587e..f266eb3 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -1,4 +1,4 @@ -using Documenter, UnitCommitment +using Documenter, UnitCommitment, JuMP makedocs( sitename="UnitCommitment.jl", diff --git a/docs/src/api.md b/docs/src/api.md index c237b7b..3445717 100644 --- a/docs/src/api.md +++ b/docs/src/api.md @@ -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 diff --git a/docs/src/format.md b/docs/src/format.md index 3f9887c..9ae9f60 100644 --- a/docs/src/format.md +++ b/docs/src/format.md @@ -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 } } } diff --git a/docs/src/index.md b/docs/src/index.md index f5cb261..9a8a013 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -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 diff --git a/docs/src/model.md b/docs/src/model.md index cb8e15d..d944b07 100644 --- a/docs/src/model.md +++ b/docs/src/model.md @@ -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 diff --git a/docs/src/usage.md b/docs/src/usage.md index 3c65b75..26d1779 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -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] +``` \ No newline at end of file diff --git a/src/UnitCommitment.jl b/src/UnitCommitment.jl index b7a88e8..3191395 100644 --- a/src/UnitCommitment.jl +++ b/src/UnitCommitment.jl @@ -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 diff --git a/src/import/egret.jl b/src/import/egret.jl index d11bdea..3acb32c 100644 --- a/src/import/egret.jl +++ b/src/import/egret.jl @@ -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"] 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 b823dc1..b095b0a 100644 --- a/src/instance/read.jl +++ b/src/instance/read.jl @@ -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)), ) diff --git a/src/instance/structs.jl b/src/instance/structs.jl index 0c72a23..9d6cc5c 100644 --- a/src/instance/structs.jl +++ b/src/instance/structs.jl @@ -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, ") diff --git a/src/lmp/aelmp.jl b/src/lmp/aelmp.jl new file mode 100644 index 0000000..7afd350 --- /dev/null +++ b/src/lmp/aelmp.jl @@ -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 diff --git a/src/lmp/conventional.jl b/src/lmp/conventional.jl new file mode 100644 index 0000000..38d07c5 --- /dev/null +++ b/src/lmp/conventional.jl @@ -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 diff --git a/src/lmp/structs.jl b/src/lmp/structs.jl new file mode 100644 index 0000000..a2816d2 --- /dev/null +++ b/src/lmp/structs.jl @@ -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 diff --git a/src/model/build.jl b/src/model/build.jl index fdfc27e..a812011 100644 --- a/src/model/build.jl +++ b/src/model/build.jl @@ -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]) diff --git a/src/model/formulations/ArrCon2000/ramp.jl b/src/model/formulations/ArrCon2000/ramp.jl index a043311..3bd48b5 100644 --- a/src/model/formulations/ArrCon2000/ramp.jl +++ b/src/model/formulations/ArrCon2000/ramp.jl @@ -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, diff --git a/src/model/formulations/CarArr2006/pwlcosts.jl b/src/model/formulations/CarArr2006/pwlcosts.jl index 9b236f7..2e133f3 100644 --- a/src/model/formulations/CarArr2006/pwlcosts.jl +++ b/src/model/formulations/CarArr2006/pwlcosts.jl @@ -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, diff --git a/src/model/formulations/DamKucRajAta2016/ramp.jl b/src/model/formulations/DamKucRajAta2016/ramp.jl index fa380c3..304430c 100644 --- a/src/model/formulations/DamKucRajAta2016/ramp.jl +++ b/src/model/formulations/DamKucRajAta2016/ramp.jl @@ -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, diff --git a/src/model/formulations/Gar1962/prod.jl b/src/model/formulations/Gar1962/prod.jl index 3e70a04..c7b8308 100644 --- a/src/model/formulations/Gar1962/prod.jl +++ b/src/model/formulations/Gar1962/prod.jl @@ -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 diff --git a/src/model/formulations/Gar1962/pwlcosts.jl b/src/model/formulations/Gar1962/pwlcosts.jl index 62d0b5c..94ac942 100644 --- a/src/model/formulations/Gar1962/pwlcosts.jl +++ b/src/model/formulations/Gar1962/pwlcosts.jl @@ -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, diff --git a/src/model/formulations/Gar1962/status.jl b/src/model/formulations/Gar1962/status.jl index 2a4a911..ea57258 100644 --- a/src/model/formulations/Gar1962/status.jl +++ b/src/model/formulations/Gar1962/status.jl @@ -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) diff --git a/src/model/formulations/KnuOstWat2018/pwlcosts.jl b/src/model/formulations/KnuOstWat2018/pwlcosts.jl index 0da8217..d3d4bdb 100644 --- a/src/model/formulations/KnuOstWat2018/pwlcosts.jl +++ b/src/model/formulations/KnuOstWat2018/pwlcosts.jl @@ -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, diff --git a/src/model/formulations/MorLatRam2013/ramp.jl b/src/model/formulations/MorLatRam2013/ramp.jl index 90afd71..29d56e3 100644 --- a/src/model/formulations/MorLatRam2013/ramp.jl +++ b/src/model/formulations/MorLatRam2013/ramp.jl @@ -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, diff --git a/src/model/formulations/MorLatRam2013/scosts.jl b/src/model/formulations/MorLatRam2013/scosts.jl index 2d68747..ce565c7 100644 --- a/src/model/formulations/MorLatRam2013/scosts.jl +++ b/src/model/formulations/MorLatRam2013/scosts.jl @@ -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) diff --git a/src/model/formulations/PanGua2016/ramp.jl b/src/model/formulations/PanGua2016/ramp.jl index 62a83ba..511f91f 100644 --- a/src/model/formulations/PanGua2016/ramp.jl +++ b/src/model/formulations/PanGua2016/ramp.jl @@ -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, diff --git a/src/model/formulations/WanHob2016/ramp.jl b/src/model/formulations/WanHob2016/ramp.jl index 417d2a3..e634fc2 100644 --- a/src/model/formulations/WanHob2016/ramp.jl +++ b/src/model/formulations/WanHob2016/ramp.jl @@ -4,7 +4,7 @@ function _add_ramp_eqs!( model::JuMP.Model, - g::Unit, + g::ThermalUnit, ::Gar1962.ProdVars, ::WanHob2016.Ramping, ::Gar1962.StatusVars, diff --git a/src/model/formulations/base/punit.jl b/src/model/formulations/base/punit.jl new file mode 100644 index 0000000..ced3ba9 --- /dev/null +++ b/src/model/formulations/base/punit.jl @@ -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 diff --git a/src/model/formulations/base/system.jl b/src/model/formulations/base/system.jl index 1591db4..7291648 100644 --- a/src/model/formulations/base/system.jl +++ b/src/model/formulations/base/system.jl @@ -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] ) diff --git a/src/model/formulations/base/unit.jl b/src/model/formulations/base/unit.jl index 27cbba6..06b18a2 100644 --- a/src/model/formulations/base/unit.jl +++ b/src/model/formulations/base/unit.jl @@ -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] diff --git a/src/solution/fix.jl b/src/solution/fix.jl index ca7703d..4193889 100644 --- a/src/solution/fix.jl +++ b/src/solution/fix.jl @@ -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], diff --git a/src/solution/solution.jl b/src/solution/solution.jl index b170f30..3cb6f41 100644 --- a/src/solution/solution.jl +++ b/src/solution/solution.jl @@ -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( diff --git a/src/solution/warmstart.jl b/src/solution/warmstart.jl index 678f250..c9fba50 100644 --- a/src/solution/warmstart.jl +++ b/src/solution/warmstart.jl @@ -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( diff --git a/src/transform/initcond.jl b/src/transform/initcond.jl index cfbce02..697d4fd 100644 --- a/src/transform/initcond.jl +++ b/src/transform/initcond.jl @@ -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) diff --git a/src/transform/randomize/XavQiuAhm2021.jl b/src/transform/randomize/XavQiuAhm2021.jl index b60cafd..630c7f0 100644 --- a/src/transform/randomize/XavQiuAhm2021.jl +++ b/src/transform/randomize/XavQiuAhm2021.jl @@ -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 diff --git a/src/transform/slice.jl b/src/transform/slice.jl index e1c6799..051e105 100644 --- a/src/transform/slice.jl +++ b/src/transform/slice.jl @@ -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] diff --git a/src/validation/repair.jl b/src/validation/repair.jl index 50ee614..440854e 100644 --- a/src/validation/repair.jl +++ b/src/validation/repair.jl @@ -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) diff --git a/src/validation/validate.jl b/src/validation/validate.jl index be8234d..5447980 100644 --- a/src/validation/validate.jl +++ b/src/validation/validate.jl @@ -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] diff --git a/test/Project.toml b/test/Project.toml index b87293d..5a175be 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -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" diff --git a/test/fixtures/aelmp_simple.json.gz b/test/fixtures/aelmp_simple.json.gz new file mode 100644 index 0000000..aaa9325 Binary files /dev/null and b/test/fixtures/aelmp_simple.json.gz differ 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/lmp_simple_test_1.json.gz b/test/fixtures/lmp_simple_test_1.json.gz new file mode 100644 index 0000000..5ec9a7f Binary files /dev/null and b/test/fixtures/lmp_simple_test_1.json.gz differ diff --git a/test/fixtures/lmp_simple_test_2.json.gz b/test/fixtures/lmp_simple_test_2.json.gz new file mode 100644 index 0000000..f23d08a Binary files /dev/null and b/test/fixtures/lmp_simple_test_2.json.gz differ diff --git a/test/fixtures/lmp_simple_test_3.json.gz b/test/fixtures/lmp_simple_test_3.json.gz new file mode 100644 index 0000000..5cb48f4 Binary files /dev/null and b/test/fixtures/lmp_simple_test_3.json.gz differ diff --git a/test/fixtures/lmp_simple_test_4.json.gz b/test/fixtures/lmp_simple_test_4.json.gz new file mode 100644 index 0000000..cddf8af Binary files /dev/null and b/test/fixtures/lmp_simple_test_4.json.gz differ diff --git a/test/import/egret_test.jl b/test/import/egret_test.jl index 0ca54ab..a0a61bf 100644 --- a/test/import/egret_test.jl +++ b/test/import/egret_test.jl @@ -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] diff --git a/test/instance/migrate_test.jl b/test/instance/migrate_test.jl index 3fe1e09..1176a01 100644 --- a/test/instance/migrate_test.jl +++ b/test/instance/migrate_test.jl @@ -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 diff --git a/test/instance/read_test.jl b/test/instance/read_test.jl index 5fa4a93..b7e27f0 100644 --- a/test/instance/read_test.jl +++ b/test/instance/read_test.jl @@ -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 diff --git a/test/lmp/aelmp_test.jl b/test/lmp/aelmp_test.jl new file mode 100644 index 0000000..484275c --- /dev/null +++ b/test/lmp/aelmp_test.jl @@ -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 diff --git a/test/lmp/conventional_test.jl b/test/lmp/conventional_test.jl new file mode 100644 index 0000000..80a4ce5 --- /dev/null +++ b/test/lmp/conventional_test.jl @@ -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 diff --git a/test/runtests.jl b/test/runtests.jl index 9a61bf2..08ade97 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/conventional_test.jl") + include("lmp/aelmp_test.jl") + end end diff --git a/test/transform/initcond_test.jl b/test/transform/initcond_test.jl index 11304bb..744eaf7 100644 --- a/test/transform/initcond_test.jl +++ b/test/transform/initcond_test.jl @@ -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 diff --git a/test/transform/randomize/XavQiuAhm2021_test.jl b/test/transform/randomize/XavQiuAhm2021_test.jl index 226db47..2c1f473 100644 --- a/test/transform/randomize/XavQiuAhm2021_test.jl +++ b/test/transform/randomize/XavQiuAhm2021_test.jl @@ -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) diff --git a/test/transform/slice_test.jl b/test/transform/slice_test.jl index 173ebbb..f4bd8f5 100644 --- a/test/transform/slice_test.jl +++ b/test/transform/slice_test.jl @@ -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