From ae10474d7c6f83c30cad1937dd39cf084dad2eda Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Sun, 27 Oct 2024 13:26:41 +0100 Subject: [PATCH 01/10] Introduced `inputs` and `outputs` for links --- docs/src/library/public/links.md | 2 + docs/src/library/public/nodes.md | 4 +- src/model.jl | 8 ++-- src/structures/link.jl | 21 +++++++++ test/runtests.jl | 4 ++ test/test_links.jl | 73 ++++++++++++++++++++++++++++++++ test/test_nodes.jl | 1 - 7 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 test/test_links.jl diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index 1f2742a..bb99f52 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -30,5 +30,7 @@ The following functions are declared for accessing fields from a `Link` type. The first approach can be achieved through using the same name for the respective fields. ```@docs +inputs(n::Link) +outputs(n::Link) formulation ``` diff --git a/docs/src/library/public/nodes.md b/docs/src/library/public/nodes.md index b9e526f..021338f 100644 --- a/docs/src/library/public/nodes.md +++ b/docs/src/library/public/nodes.md @@ -101,8 +101,8 @@ The following functions are declared for accessing fields from a `Node` type. capacity opex_var opex_fixed -inputs -outputs +inputs(n::EnergyModelsBase.Node) +outputs(n::EnergyModelsBase.Node) node_data charge level diff --git a/src/model.jl b/src/model.jl index 3cbb407..1040e66 100644 --- a/src/model.jl +++ b/src/model.jl @@ -151,8 +151,8 @@ function variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) @variable(m, flow_in[n_in ∈ 𝒩ⁱⁿ, 𝒯, inputs(n_in)] >= 0) @variable(m, flow_out[n_out ∈ 𝒩ᵒᵘᵗ, 𝒯, outputs(n_out)] >= 0) - @variable(m, link_in[l ∈ ℒ, 𝒯, link_res(l)] >= 0) - @variable(m, link_out[l ∈ ℒ, 𝒯, link_res(l)] >= 0) + @variable(m, link_in[l ∈ ℒ, 𝒯, inputs(l)] >= 0) + @variable(m, link_out[l ∈ ℒ, 𝒯, outputs(l)] >= 0) end """ @@ -276,14 +276,14 @@ function constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) if has_output(n) @constraint(m, [t ∈ 𝒯, p ∈ outputs(n)], m[:flow_out][n, t, p] == - sum(m[:link_in][l, t, p] for l ∈ ℒᶠʳᵒᵐ if p ∈ inputs(l.to)) + sum(m[:link_in][l, t, p] for l ∈ ℒᶠʳᵒᵐ if p ∈ outputs(l)) ) end # Constraint for input flowrate and output links. if has_input(n) @constraint(m, [t ∈ 𝒯, p ∈ inputs(n)], m[:flow_in][n, t, p] == - sum(m[:link_out][l, t, p] for l ∈ ℒᵗᵒ if p ∈ outputs(l.from)) + sum(m[:link_out][l, t, p] for l ∈ ℒᵗᵒ if p ∈ inputs(l)) ) end # Call of function for individual node constraints. diff --git a/src/structures/link.jl b/src/structures/link.jl index 67679e5..64cb8d9 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -41,9 +41,30 @@ end link_res(l::Link) Return the resources transported for a given link `l`. + +The default approach is to use the intersection of the inputs of the `to` node and the +outputs of the `from` node. """ link_res(l::Link) = intersect(inputs(l.to), outputs(l.from)) +""" + inputs(n::Link) + +Returns the input resources of a link `l`. + +The default approach is to use the function [`link_res(l::Link)`](@ref). +""" +inputs(l::Link) = link_res(l) + +""" + outputs(n::Link) + +Returns the output resources of a link `l`. + +The default approach is to use the function [`link_res(l::Link)`](@ref). +""" +outputs(l::Link) = link_res(l) + """ formulation(l::Link) diff --git a/test/runtests.jl b/test/runtests.jl index 0cfaea3..eb38f0d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -20,6 +20,10 @@ ENV["EMB_TEST"] = true # Set flag for example scripts to check if they are run a include("test_nodes.jl") end + @testset "Base | Link" begin + include("test_links.jl") + end + @testset "Base | Modeltype" begin include("test_modeltype.jl") end diff --git a/test/test_links.jl b/test/test_links.jl new file mode 100644 index 0000000..40c4e74 --- /dev/null +++ b/test/test_links.jl @@ -0,0 +1,73 @@ + +@testset "Link utilities" begin + + # Resources used in the analysis + NG = ResourceEmit("NG", 0.2) + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Function for setting up the system + function simple_graph() + # Used source, network, and sink + source = RefSource( + "source", + FixedProfile(4), + FixedProfile(10), + FixedProfile(0), + Dict(NG => 1), + ) + network = RefNetworkNode( + "network", + FixedProfile(25), + FixedProfile(5.5), + FixedProfile(0), + Dict(NG => 2), + Dict(Power => 1), + Data[EmissionsEnergy()], + ) + + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(100)), + Dict(Power => 1), + ) + + resources = [NG, Power, CO2] + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(2, 2, ops; op_per_strat) + + nodes = [source, network, sink] + links = [ + Direct(12, source, network) + Direct(23, network, sink) + Direct(23, source, sink) + ] + model = OperationalModel( + Dict(CO2 => FixedProfile(100), NG => FixedProfile(100)), + Dict(CO2 => FixedProfile(0), NG => FixedProfile(0)), + CO2, + ) + case = Dict(:T => T, :nodes => nodes, :links => links, :products => resources) + return case, model + end + + @testset "Access functions" begin + case, model = simple_graph() + ℒ = case[:links] + 𝒩 = case[:nodes] + + # Test that the tranported resources are correctly identified + @test inputs(ℒ[1]) == outputs(𝒩[1]) + @test outputs(ℒ[1]) == inputs(𝒩[2]) + + # Test that the function `link_res` does not return a transported resources for the + # 3ʳᵈ link + @test isempty(EMB.link_res(ℒ[3])) + + # Test that the constructor for a direct link is working and that the function + # formulation is working + @test isa(formulation(ℒ[1]), Linear) + end +end diff --git a/test/test_nodes.jl b/test/test_nodes.jl index b6e70e1..62ed973 100644 --- a/test/test_nodes.jl +++ b/test/test_nodes.jl @@ -83,7 +83,6 @@ end @testset "General tests - RefSink" begin - # Test that the deficit values are properly calculated and time is involved # in the penalty calculation source = RefSource( From 113b8f30d1af94d55162a8540e996d22014e5bb3 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Sun, 27 Oct 2024 14:21:56 +0100 Subject: [PATCH 02/10] Added function `is_unidirectional` - Relaxed lower bounds for flows - Manual fix of the lower bounds of variables --- docs/src/library/public/links.md | 10 +- docs/src/library/public/nodes.md | 1 + src/EnergyModelsBase.jl | 2 +- src/model.jl | 30 +++++- src/structures/link.jl | 14 +++ src/structures/node.jl | 14 +++ test/test_links.jl | 24 +++++ test/test_nodes.jl | 161 +++++++++++++++++++++++++++++++ 8 files changed, 250 insertions(+), 6 deletions(-) diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index bb99f52..3297b11 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -21,7 +21,7 @@ Direct Linear ``` -## [Functions for accessing fields of `Link` types](@id lib-pub-fun_field) +## [Functions for accessing fields of `Link` types](@id lib-pub-links-fun_field) The following functions are declared for accessing fields from a `Link` type. @@ -34,3 +34,11 @@ inputs(n::Link) outputs(n::Link) formulation ``` + +## [Functions for identifying `Link`s](@id lib-pub-links-fun_identify) + +The following functions are declared for filtering on `Link` types. + +```@docs +is_unidirectional(l::Link) +``` diff --git a/docs/src/library/public/nodes.md b/docs/src/library/public/nodes.md index 021338f..3ec98eb 100644 --- a/docs/src/library/public/nodes.md +++ b/docs/src/library/public/nodes.md @@ -133,4 +133,5 @@ has_output has_emissions has_charge has_discharge +is_unidirectional(n::EnergyModelsBase.Node) ``` diff --git a/src/EnergyModelsBase.jl b/src/EnergyModelsBase.jl index 5707df6..709bea3 100644 --- a/src/EnergyModelsBase.jl +++ b/src/EnergyModelsBase.jl @@ -87,7 +87,7 @@ export co2_capture, process_emissions # Export commonly used functions for extracting fields in `Node` export nodes_input, nodes_output, nodes_emissions -export has_input, has_output, has_emissions +export has_input, has_output, has_emissions, is_unidirectional export capacity, inputs, outputs, diff --git a/src/model.jl b/src/model.jl index 1040e66..f6611e6 100644 --- a/src/model.jl +++ b/src/model.jl @@ -143,16 +143,38 @@ end Declaration of the individual input (`:flow_in`) and output (`:flow_out`) flowrates for each technological node `n ∈ 𝒩` and link `l ∈ ℒ` (`:link_in` and `:link_out`). + +By default, all nodes `𝒩` and links ℒ only allow for unidirectional flow. """ function variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) 𝒩ⁱⁿ = filter(has_input, 𝒩) 𝒩ᵒᵘᵗ = filter(has_output, 𝒩) - @variable(m, flow_in[n_in ∈ 𝒩ⁱⁿ, 𝒯, inputs(n_in)] >= 0) - @variable(m, flow_out[n_out ∈ 𝒩ᵒᵘᵗ, 𝒯, outputs(n_out)] >= 0) + @variable(m, flow_in[n_in ∈ 𝒩ⁱⁿ, 𝒯, inputs(n_in)]) + @variable(m, flow_out[n_out ∈ 𝒩ᵒᵘᵗ, 𝒯, outputs(n_out)]) + + @variable(m, link_in[l ∈ ℒ, 𝒯, inputs(l)]) + @variable(m, link_out[l ∈ ℒ, 𝒯, outputs(l)]) - @variable(m, link_in[l ∈ ℒ, 𝒯, inputs(l)] >= 0) - @variable(m, link_out[l ∈ ℒ, 𝒯, outputs(l)] >= 0) + # Set the bounds fo unidirectional nodes and links + 𝒩ⁱⁿ⁻ᵘⁿⁱ = filter(is_unidirectional, 𝒩ⁱⁿ) + 𝒩ᵒᵘᵗ⁻ᵘⁿⁱ = filter(is_unidirectional, 𝒩ᵒᵘᵗ) + ℒᵘⁿⁱ = filter(is_unidirectional, ℒ) + + for n_in ∈ 𝒩ⁱⁿ⁻ᵘⁿⁱ, t ∈ 𝒯, p ∈ inputs(n_in) + set_lower_bound(m[:flow_in][n_in, t, p], 0) + end + for n_out ∈ 𝒩ᵒᵘᵗ⁻ᵘⁿⁱ, t ∈ 𝒯, p ∈ outputs(n_out) + set_lower_bound(m[:flow_out][n_out, t, p], 0) + end + for l ∈ ℒᵘⁿⁱ, t ∈ 𝒯 + for p ∈ inputs(l) + set_lower_bound(m[:link_in][l, t, p], 0) + end + for p ∈ outputs(l) + set_lower_bound(m[:link_out][l, t, p], 0) + end + end end """ diff --git a/src/structures/link.jl b/src/structures/link.jl index 64cb8d9..9a58505 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -37,6 +37,20 @@ function link_sub(ℒ::Vector{<:Link}, n::Node) return [filter(x -> x.from == n, ℒ), filter(x -> x.to == n, ℒ)] end +""" + is_unidirectional(l::Link) + +Returns logic whether the link `l` can be used bidirectional or only unidirectional. + +!!! note "Bidirectional flow in links" + In the current stage, `EnergyModelsBase` does not include any links which can be used + bidirectional, that is with flow reversal. + + If you plan to use bidirectional flow, you have to declare your own nodes and links which + support this. You can then dispatch on this function for the incorporation. +""" +is_unidirectional(l::Link) = true + """ link_res(l::Link) diff --git a/src/structures/node.jl b/src/structures/node.jl index d91dcd5..dd45de7 100644 --- a/src/structures/node.jl +++ b/src/structures/node.jl @@ -474,6 +474,20 @@ Returns logic whether the node is an output node, *i.e.*, [`Source`](@ref) and has_output(n::Node) = true has_output(n::Sink) = false +""" + is_unidirectional(n::Node) + +Returns logic whether the node `n` can be used bidirectional or only unidirectional. + +!!! note "Bidirectional flow in nodes" + In the current stage, `EnergyModelsBase` does not include any nodes which can be used + bidirectional, that is with flow reversal. + + If you plan to use bidirectional flow, you have to declare your own nodes and links that + support this. You can then dispatch on this function for the incorporation. +""" +is_unidirectional(n::Node) = true + """ has_charge(n::Storage) diff --git a/test/test_links.jl b/test/test_links.jl index 40c4e74..301730b 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -53,6 +53,13 @@ return case, model end + @testset "Identification functions" begin + case, model = simple_graph() + + # Test that all links are identified as unidirectional + @test all(is_unidirectional(l) for l ∈ case[:links]) + end + @testset "Access functions" begin case, model = simple_graph() ℒ = case[:links] @@ -70,4 +77,21 @@ # formulation is working @test isa(formulation(ℒ[1]), Linear) end + + @testset "Variable declaration" begin + case, model = simple_graph() + m = create_model(case, model) + ℒ = case[:links] + 𝒯 = case[:T] + + # Test that all link variables have a lower bound of 0 + @test all( + all(lower_bound(m[:link_in][l, t, p]) == 0 for p ∈ inputs(l)) + for l ∈ ℒ, t ∈ 𝒯 + ) + @test all( + all(lower_bound(m[:link_out][l, t, p]) == 0 for p ∈ outputs(l)) + for l ∈ ℒ, t ∈ 𝒯 + ) + end end diff --git a/test/test_nodes.jl b/test/test_nodes.jl index 62ed973..46a154d 100644 --- a/test/test_nodes.jl +++ b/test/test_nodes.jl @@ -1,4 +1,165 @@ +@testset "Node utilities" begin + + # Resources used in the analysis + NG = ResourceEmit("NG", 0.2) + Coal = ResourceCarrier("Coal", 0.35) + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Function for setting up the system + function simple_graph() + # Define the different resources + NG = ResourceEmit("NG", 0.2) + Coal = ResourceCarrier("Coal", 0.35) + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + products = [NG, Coal, Power, CO2] + + # Creation of the emission data for the individual nodes. + capture_data = CaptureEnergyEmissions(0.9) + emission_data = EmissionsEnergy() + + # Create the individual test nodes, corresponding to a system with an electricity demand/sink, + # coal and nautral gas sources, coal and natural gas (with CCS) power plants and CO2 storage. + nodes = [ + GenAvailability(1, products), + RefSource(2, FixedProfile(1e12), FixedProfile(30), FixedProfile(0), Dict(NG => 1)), + RefSource(3, FixedProfile(1e12), FixedProfile(9), FixedProfile(0), Dict(Coal => 1)), + RefNetworkNode( + 4, + FixedProfile(25), + FixedProfile(5.5), + FixedProfile(5), + Dict(NG => 2), + Dict(Power => 1, CO2 => 1), + [capture_data], + ), + RefNetworkNode( + 5, + FixedProfile(25), + FixedProfile(6), + FixedProfile(10), + Dict(Coal => 2.5), + Dict(Power => 1), + [emission_data], + ), + RefStorage{AccumulatingEmissions}( + 6, + StorCapOpex(FixedProfile(60), FixedProfile(9.1), FixedProfile(0)), + StorCap(FixedProfile(600)), + CO2, + Dict(CO2 => 1, Power => 0.02), + Dict(CO2 => 1), + ), + RefSink( + 7, + OperationalProfile([20, 30, 40, 30]), + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e6)), + Dict(Power => 1), + ), + ] + + # Connect all nodes with the availability node for the overall energy/mass balance + links = [ + Direct(14, nodes[1], nodes[4], Linear()) + Direct(15, nodes[1], nodes[5], Linear()) + Direct(16, nodes[1], nodes[6], Linear()) + Direct(17, nodes[1], nodes[7], Linear()) + Direct(21, nodes[2], nodes[1], Linear()) + Direct(31, nodes[3], nodes[1], Linear()) + Direct(41, nodes[4], nodes[1], Linear()) + Direct(51, nodes[5], nodes[1], Linear()) + Direct(61, nodes[6], nodes[1], Linear()) + ] + + # Creation of the time structure and global data + T = TwoLevel(4, 2, SimpleTimes(4, 2), op_per_strat = 8) + model = OperationalModel( + Dict(CO2 => StrategicProfile([160, 140, 120, 100]), NG => FixedProfile(1e6)), + Dict(CO2 => FixedProfile(0)), + CO2, + ) + + # WIP data structure + case = Dict(:nodes => nodes, :links => links, :products => products, :T => T) + return case, model + end + + @testset "Identification functions" begin + case, model = simple_graph() + 𝒩 = case[:nodes] + stor = 𝒩[6] + + # Test that all nodal supertypes are identified correctly + @test [EMB.is_source(n) for n ∈ 𝒩] == + [false, true, true, false, false, false, false] + @test [EMB.is_network_node(n) for n ∈ 𝒩] == + [true, false, false, true, true, true, false] + @test [EMB.is_storage(n) for n ∈ 𝒩] == + [false, false, false, false, false, true, false] + @test [EMB.is_sink(n) for n ∈ 𝒩] == + [false, false, false, false, false, false, true] + + # Test that the corrects nodes with emissions are identified + @test [EMB.has_emissions(n) for n ∈ 𝒩] == + [false, false, false, true, true, true, false] + + # Test that the corrects nodes with input and output are identified + @test [has_output(n) for n ∈ 𝒩] == + [true, true, true, true, true, true, false] + @test [has_input(n) for n ∈ 𝒩] == + [true, false, false, true, true, true, true] + + # Test that all nodes are identified as unidirectional + @test all(is_unidirectional(n) for n ∈ 𝒩) + + # Test that the storage node is correctly identified + @test all([ + has_charge(stor), EMB.has_charge_cap(stor), EMB.has_charge_OPEX_fixed(stor), + EMB.has_charge_OPEX_var(stor), + !EMB.has_level_OPEX_fixed(stor), !EMB.has_level_OPEX_var(stor), + !has_discharge(stor), !EMB.has_discharge_cap(stor), + !EMB.has_discharge_OPEX_fixed(stor), !EMB.has_discharge_OPEX_var(stor), + ]) + end + + @testset "Access functions" begin + case, model = simple_graph() + 𝒩 = case[:nodes] + + # Test that the input and output resources are correctly identified + @test outputs(𝒩[2]) == [NG] + @test outputs(𝒩[2], NG) == 1 + @test outputs(𝒩[4]) == [CO2, Power] || outputs(𝒩[4]) == [Power, CO2] + @test inputs(𝒩[6]) == [CO2, Power] || inputs(𝒩[6]) == [Power, CO2] + @test inputs(𝒩[7]) == [Power] + @test inputs(𝒩[7], Power) == 1 + end + + @testset "Variable declaration" begin + case, model = simple_graph() + m = create_model(case, model) + + 𝒩 = case[:nodes] + stor = 𝒩[6] + 𝒩ⁱⁿ = filter(has_input, 𝒩) + 𝒩ᵒᵘᵗ = setdiff(filter(has_output, 𝒩), [stor]) # The storage has a fixed output variable + 𝒯 = case[:T] + + # Test that all link variables have a lower bound of 0 + @test all( + all(lower_bound(m[:flow_in][n_in, t, p]) == 0 for p ∈ inputs(n_in)) + for n_in ∈ 𝒩ⁱⁿ, t ∈ 𝒯 + ) + @test all( + all(lower_bound(m[:flow_out][n_out, t, p]) == 0 for p ∈ outputs(n_out)) + for n_out ∈ 𝒩ᵒᵘᵗ, t ∈ 𝒯 + ) + end +end + + @testset "Test RefSource and RefSink" begin # Resources used in the analysis From 8110d8da1a3a81d5928767376fe6959a92f2ff37 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Sun, 27 Oct 2024 15:29:47 +0100 Subject: [PATCH 03/10] Added potential for links with emissions --- docs/src/library/public/links.md | 1 + docs/src/library/public/nodes.md | 2 +- docs/src/manual/optimization-variables.md | 13 ++++ src/model.jl | 27 ++++--- src/structures/link.jl | 10 +++ test/test_links.jl | 90 ++++++++++++++++++++++- 6 files changed, 131 insertions(+), 12 deletions(-) diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index 3297b11..069e9b5 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -40,5 +40,6 @@ formulation The following functions are declared for filtering on `Link` types. ```@docs +has_emissions(l::Link) is_unidirectional(l::Link) ``` diff --git a/docs/src/library/public/nodes.md b/docs/src/library/public/nodes.md index 3ec98eb..e6fcb36 100644 --- a/docs/src/library/public/nodes.md +++ b/docs/src/library/public/nodes.md @@ -130,7 +130,7 @@ nodes_output nodes_emissions has_input has_output -has_emissions +has_emissions(n::EnergyModelsBase.Node) has_charge has_discharge is_unidirectional(n::EnergyModelsBase.Node) diff --git a/docs/src/manual/optimization-variables.md b/docs/src/manual/optimization-variables.md index 6a1570e..d58cecd 100644 --- a/docs/src/manual/optimization-variables.md +++ b/docs/src/manual/optimization-variables.md @@ -136,6 +136,19 @@ The following node variable is then declared for all emission resource 𝒫ᵉ - ``\texttt{emissions\_node}[n, t, p_\texttt{em}]``: Emissions of node ``n`` at operational period ``t`` of emission resource ``p_\texttt{em}``. +Similarly, it is not necessary that links have associated emission variables. +Emission variables are only created for a link ``l`` if the function [`has_emissions(n::Link)`](@ref) returns `true`. +The following link variable is then declared for all emission resource 𝒫ᵉᵐ: + +- ``\texttt{emissions\_link}[n, t, p_\texttt{em}]``: Emissions of link ``l`` at operational period ``t`` of emission resource ``p_\texttt{em}``. + +!!! tip "Links with emissions" + All links introduced in `EnergyModelsBase` do not allow for emissions. + If you plan to introduce a link with emissions, you have to create a new method for the function `has_emissions` for your introduced link. + + We have not implemented a similar approach as for nodes. + It is however planned to allow for transmission emissions in the near future, similar to the concept employed for process emissions for nodes. + In addition, `EnergyModelsBase` declares the following variables for the global emissions: - ``\texttt{emissions\_total}[t, p_\texttt{em}]``: Total emissions of `ResourceEmit` ``p_\texttt{em}`` in operational period ``t``, and diff --git a/src/model.jl b/src/model.jl index f6611e6..ddad71e 100644 --- a/src/model.jl +++ b/src/model.jl @@ -55,7 +55,7 @@ function create_model( # Declaration of variables for the problem variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) - variables_emission(m, 𝒩, 𝒯, 𝒫, modeltype) + variables_emission(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) variables_opex(m, 𝒩, 𝒯, 𝒫, modeltype) variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype) variables_capacity(m, 𝒩, 𝒯, modeltype) @@ -63,7 +63,7 @@ function create_model( # Construction of constraints for the problem constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) - constraints_emissions(m, 𝒩, 𝒯, 𝒫, modeltype) + constraints_emissions(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) constraints_links(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) # Construction of the objective function @@ -178,23 +178,30 @@ function variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) end """ - variables_emission(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) + variables_emission(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) -Declaration of emission variables per technology node with emissions `n ∈ 𝒩ᵉᵐ` and emission -resource `𝒫ᵉᵐ ∈ 𝒫`. +Declaration of emission variables per technology node with emissions `n ∈ 𝒩ᵉᵐ` and link with +emissions `l ∈ ℒᵉᵐ` for each emission resource `𝒫ᵉᵐ ∈ 𝒫`. + +The inclusion of node and link emissions require that the function `has_emissions` returns +a value `true` for the given node or link. This is by default achieved for nodes through +inclusion of `EmissionData`. The emission variables are differentiated in: * `:emissions_node` - emissions of a node in an operational period, +* `:emissions_link` - emissions of a link in an operational period, * `:emissions_total` - total emissions in an operational period, and * `:emissions_strategic` - total strategic emissions, constrained to an upper limit based on the field `emission_limit` of the `EnergyModel`. """ -function variables_emission(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) +function variables_emission(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) 𝒩ᵉᵐ = filter(has_emissions, 𝒩) + ℒᵉᵐ = filter(has_emissions, ℒ) 𝒫ᵉᵐ = filter(is_resource_emit, 𝒫) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) @variable(m, emissions_node[𝒩ᵉᵐ, 𝒯, 𝒫ᵉᵐ]) + @variable(m, emissions_link[ℒᵉᵐ, 𝒯, 𝒫ᵉᵐ] ≥ 0) @variable(m, emissions_total[𝒯, 𝒫ᵉᵐ]) @variable(m, emissions_strategic[t_inv ∈ 𝒯ᴵⁿᵛ, p ∈ 𝒫ᵉᵐ] <= emission_limit(modeltype, p, t_inv) @@ -314,19 +321,21 @@ function constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) end """ - constraints_emissions(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) + constraints_emissions(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) Create constraints for the emissions accounting for both operational and strategic periods. """ -function constraints_emissions(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) +function constraints_emissions(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) 𝒩ᵉᵐ = filter(has_emissions, 𝒩) + ℒᵉᵐ = filter(has_emissions, ℒ) 𝒫ᵉᵐ = filter(is_resource_emit, 𝒫) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) # Creation of the individual constraints. @constraint(m, con_em_tot[t ∈ 𝒯, p ∈ 𝒫ᵉᵐ], m[:emissions_total][t, p] == - sum(m[:emissions_node][n, t, p] for n ∈ 𝒩ᵉᵐ) + sum(m[:emissions_node][n, t, p] for n ∈ 𝒩ᵉᵐ) + + sum(m[:emissions_link][l, t, p] for l ∈ ℒᵉᵐ) ) @constraint(m, [t_inv ∈ 𝒯ᴵⁿᵛ, p ∈ 𝒫ᵉᵐ], m[:emissions_strategic][t_inv, p] == diff --git a/src/structures/link.jl b/src/structures/link.jl index 9a58505..ef1b1c9 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -51,6 +51,16 @@ Returns logic whether the link `l` can be used bidirectional or only unidirectio """ is_unidirectional(l::Link) = true +""" + has_emissions(l::Link) + +Checks whether link `l` has emissions. + +By default, links do not have emissions. You must dispatch on this function if you want to +introduce links with associated emissions, *e.g.*, through leakage. +""" +has_emissions(l::Link) = false + """ link_res(l::Link) diff --git a/test/test_links.jl b/test/test_links.jl index 301730b..a8fe165 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -1,4 +1,3 @@ - @testset "Link utilities" begin # Resources used in the analysis @@ -55,9 +54,13 @@ @testset "Identification functions" begin case, model = simple_graph() + ℒ = case[:links] # Test that all links are identified as unidirectional - @test all(is_unidirectional(l) for l ∈ case[:links]) + @test all(is_unidirectional(l) for l ∈ ℒ) + + # Test that all links do not have emissions + @test !all(has_emissions(l) for l ∈ ℒ) end @testset "Access functions" begin @@ -72,6 +75,7 @@ # Test that the function `link_res` does not return a transported resources for the # 3ʳᵈ link @test isempty(EMB.link_res(ℒ[3])) + @test isempty(EMB.link_res(ℒ[3])) # Test that the constructor for a direct link is working and that the function # formulation is working @@ -93,5 +97,87 @@ all(lower_bound(m[:link_out][l, t, p]) == 0 for p ∈ outputs(l)) for l ∈ ℒ, t ∈ 𝒯 ) + + # Test that `emissions_link` variable is empty + @test isempty(m[:emissions_link]) + end +end + +@testset "Link emissions" begin + # Creation of a new link type with associated emissions in each operational period + struct EmissionDirect <: Link + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + function EMB.create_link(m, 𝒯, 𝒫, l::EmissionDirect, formulation::EMB.Formulation) + + # Generic link in which each output corresponds to the input + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] == m[:link_in][l, t, p] + ) + + # Emissions + 𝒫ᵉᵐ = filter(EMB.is_resource_emit, 𝒫) + @constraint(m, [t ∈ 𝒯, p_em ∈ 𝒫ᵉᵐ], + m[:emissions_link][l, t, p_em] == 0.1 + ) + end + EMB.has_emissions(l::EmissionDirect) = true + + # Resources used in the analysis + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Function for setting up the system + function simple_graph() + # Used source, network, and sink + source = RefSource( + "source", + FixedProfile(4), + FixedProfile(10), + FixedProfile(0), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(100)), + Dict(Power => 1), + ) + + resources = [Power, CO2] + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(2, 2, ops; op_per_strat) + + nodes = [source, sink] + links = [ + EmissionDirect(23, source, sink, Linear()) + ] + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => FixedProfile(0)), + CO2, + ) + case = Dict(:T => T, :nodes => nodes, :links => links, :products => resources) + return case, model end + + case, model = simple_graph() + m = run_model(case, model, HiGHS.Optimizer) + ℒ = case[:links] + 𝒩 = case[:nodes] + 𝒯 = case[:T] + + # Test that `emissions_link` variable is not empty + @test !isempty(m[:emissions_link]) + + # Test that the value of the emission variable is included in the total emissions + @test all( + value.(m[:emissions_total][t, CO2]) == + value.(m[:emissions_link][ℒ[1], t, CO2]) + for t ∈ 𝒯) + @test all(value.(m[:emissions_total][t, CO2]) == 0.1 for t ∈ 𝒯) end From 166fe57c3066b08371095bda59b2f2c3b344bd42 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Sun, 27 Oct 2024 16:31:03 +0100 Subject: [PATCH 04/10] Added support for links with OPEX - It always creates both fixed and variable - Tested for both EMI and EMB, but to be extended in EMI tests later --- docs/src/library/internals/functions.md | 3 +- .../src/library/internals/reference_EMIExt.md | 2 +- docs/src/library/public/links.md | 1 + docs/src/manual/optimization-variables.md | 12 ++ ext/EMIExt/objective.jl | 15 +- src/EnergyModelsBase.jl | 1 + src/model.jl | 47 ++++-- src/structures/link.jl | 10 ++ test/test_links.jl | 135 ++++++++++++------ 9 files changed, 167 insertions(+), 59 deletions(-) diff --git a/docs/src/library/internals/functions.md b/docs/src/library/internals/functions.md index 1b3a159..9a7f04e 100644 --- a/docs/src/library/internals/functions.md +++ b/docs/src/library/internals/functions.md @@ -14,7 +14,7 @@ CurrentModule = EnergyModelsBase ```@docs create_link -objective(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) +objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) ``` ## Constraint functions @@ -38,6 +38,7 @@ variables_emission variables_flow variables_opex variables_nodes +variables_links_opex ``` ## Check functions diff --git a/docs/src/library/internals/reference_EMIExt.md b/docs/src/library/internals/reference_EMIExt.md index 37e72de..1bb53b0 100644 --- a/docs/src/library/internals/reference_EMIExt.md +++ b/docs/src/library/internals/reference_EMIExt.md @@ -34,7 +34,7 @@ check_inv_data ```@docs EMB.variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) EMB.constraints_capacity_installed(m, n::EMB.Node, 𝒯::TimeStructure, modeltype::AbstractInvestmentModel) -EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) +EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentModel) EMB.check_node_data(n::EMB.Node, data::InvestmentData, 𝒯, modeltype::AbstractInvestmentModel, check_timeprofiles::Bool) ``` diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index 069e9b5..fe7a976 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -41,5 +41,6 @@ The following functions are declared for filtering on `Link` types. ```@docs has_emissions(l::Link) +has_opex(l::Link) is_unidirectional(l::Link) ``` diff --git a/docs/src/manual/optimization-variables.md b/docs/src/manual/optimization-variables.md index d58cecd..3dd5c62 100644 --- a/docs/src/manual/optimization-variables.md +++ b/docs/src/manual/optimization-variables.md @@ -47,6 +47,18 @@ Instead, it is only dependent on the installed capacity. It is calculated using the function [`constraints_opex_fixed`](@ref). It represents fixed costs like labour cost, maintenance, as well as insurances and taxes. +We also introduce the potential for links with operational costs. +By default, links do not introduce new variables. +Operational cost variables are only created for a link ``l`` if the function [`has_opex(n::Link)`](@ref) returns `true`. +The following link variables are then declared representing the operational costs of the links: + +- ``\texttt{link\_opex\_var}[l, t_\texttt{inv}]``: Variable OPEX of link ``l`` in strategic period ``t_\texttt{inv}``. +- ``\texttt{link\_opex\_foxed}[l, t_\texttt{inv}]``: Fixed OPEX of link ``l`` in strategic period ``t_\texttt{inv}``. + +!!! tip "Links with OPEX" + All links introduced in `EnergyModelsBase` do not allow for operational costs. + If you plan to introduce a link with operational costs, you have to create a new method for the function `has_opex` for your introduced link. + ## [Capacity variables](@id man-opt_var-cap) Capacity variables focus on both the capacity usage and installed capacity. diff --git a/ext/EMIExt/objective.jl b/ext/EMIExt/objective.jl index b91cc34..5c7791d 100644 --- a/ext/EMIExt/objective.jl +++ b/ext/EMIExt/objective.jl @@ -1,5 +1,5 @@ """ - EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) + EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentModel) Create objective function overloading the default from EMB for `AbstractInvestmentModel`. @@ -14,10 +14,12 @@ These variables would need to be introduced through the package `SparsVariables` Both are not necessary, as it is possible to include them through the OPEX values, but it would be beneficial for a better separation and simpler calculations from the results. """ -function EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) +function EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentModel) # Extraction of the individual subtypes for investments in nodes 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + # Filtering through the individual nodes 𝒩ᶜᵃᵖ = EMB.nodes_not_av(𝒩) # Nodes with capacity 𝒩ᴵⁿᵛ = filter(has_investment, filter(!EMB.is_storage, 𝒩)) 𝒩ˢᵗᵒʳ = filter(EMB.is_storage, 𝒩) @@ -25,6 +27,9 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) 𝒩ᶜʰᵃʳᵍᵉ = filter(n -> has_investment(n, :charge), 𝒩ˢᵗᵒʳ) 𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ = filter(n -> has_investment(n, :discharge), 𝒩ˢᵗᵒʳ) + # Filtering through the individual links + ℒᵒᵖᵉˣ = filter(has_opex, ℒ) + 𝒫ᵉᵐ = filter(EMB.is_resource_emit, 𝒫) # Emissions resources disc = Discounter(discount_rate(modeltype), 𝒯) @@ -34,6 +39,10 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) sum((m[:opex_var][n, t_inv] + m[:opex_fixed][n, t_inv]) for n ∈ 𝒩ᶜᵃᵖ) ) + link_opex = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], + sum((m[:link_opex_var][l, t_inv] + m[:link_opex_fixed][l, t_inv]) for l ∈ ℒᵒᵖᵉˣ) + ) + # Calculation of the emission costs contribution emissions = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], sum( @@ -57,7 +66,7 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) # Calculation of the objective function. @objective(m, Max, -sum( - (opex[t_inv] + emissions[t_inv]) * + (opex[t_inv] + link_opex[t_inv] + emissions[t_inv]) * duration_strat(t_inv) * objective_weight(t_inv, disc; type = "avg") + (capex_cap[t_inv] + capex_stor[t_inv]) * objective_weight(t_inv, disc) for t_inv ∈ 𝒯ᴵⁿᵛ) diff --git a/src/EnergyModelsBase.jl b/src/EnergyModelsBase.jl index 709bea3..7ce5b2c 100644 --- a/src/EnergyModelsBase.jl +++ b/src/EnergyModelsBase.jl @@ -103,6 +103,7 @@ export has_charge, has_discharge export charge, level, discharge # Export commonly used functions for extracting fields in `Link` +export has_opex export formulation # Export commonly used functions for extracting fields in `EnergyModel` diff --git a/src/model.jl b/src/model.jl index ddad71e..eac1f8b 100644 --- a/src/model.jl +++ b/src/model.jl @@ -61,13 +61,15 @@ function create_model( variables_capacity(m, 𝒩, 𝒯, modeltype) variables_nodes(m, 𝒩, 𝒯, modeltype) + variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype) + # Construction of constraints for the problem constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) constraints_emissions(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) constraints_links(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) # Construction of the objective function - objective(m, 𝒩, 𝒯, 𝒫, modeltype) + objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) return m end @@ -212,7 +214,9 @@ end variables_opex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) Declaration of the OPEX variables (`:opex_var` and `:opex_fixed`) of the model for each -period `𝒯ᴵⁿᵛ ∈ 𝒯`. Variable OPEX can be non negative to account for revenue streams. +investment period `t_inv ∈ 𝒯ᴵⁿᵛ`. + +Variable OPEX can be negative to account for revenue streams. """ function variables_opex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) 𝒩ⁿᵒᵗ = nodes_not_av(𝒩) @@ -225,11 +229,29 @@ end """ variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) -Declaration of the CAPEX variables of the model for each investment period `𝒯ᴵⁿᵛ ∈ 𝒯`. +Declaration of the CAPEX variables of the model for each investment period `t_inv ∈ 𝒯ᴵⁿᵛ`. Empty for operational models but required for multiple dispatch in investment model. """ function variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) end +""" + variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype::EnergyModel) + +Declaration of the OPEX variables for links (`:link_opex_var` and `:link_opex_fixed`) of +the model for each investment period `t_inv ∈ 𝒯ᴵⁿᵛ`. The OPEX variables are only created for +links, if the function [`has_opex`](@ref) has received an additional method for a given +link `l` returning the value `true`. + +Variable OPEX can be negative to account for revenue streams. +""" +function variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype::EnergyModel) + ℒᵒᵖᵉˣ = filter(has_opex, ℒ) + 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + @variable(m, link_opex_var[ℒᵒᵖᵉˣ, 𝒯ᴵⁿᵛ]) + @variable(m, link_opex_fixed[ℒᵒᵖᵉˣ, 𝒯ᴵⁿᵛ] ≥ 0) +end + """ variables_nodes(m, 𝒩, 𝒯, modeltype::EnergyModel) @@ -334,17 +356,17 @@ function constraints_emissions(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) # Creation of the individual constraints. @constraint(m, con_em_tot[t ∈ 𝒯, p ∈ 𝒫ᵉᵐ], m[:emissions_total][t, p] == - sum(m[:emissions_node][n, t, p] for n ∈ 𝒩ᵉᵐ) + - sum(m[:emissions_link][l, t, p] for l ∈ ℒᵉᵐ) + sum(m[:emissions_node][n, t, p] for n ∈ 𝒩ᵉᵐ) + + sum(m[:emissions_link][l, t, p] for l ∈ ℒᵉᵐ) ) @constraint(m, [t_inv ∈ 𝒯ᴵⁿᵛ, p ∈ 𝒫ᵉᵐ], m[:emissions_strategic][t_inv, p] == - sum(m[:emissions_total][t, p] * scale_op_sp(t_inv, t) for t ∈ t_inv) + sum(m[:emissions_total][t, p] * scale_op_sp(t_inv, t) for t ∈ t_inv) ) end """ - objective(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) + objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) Create the objective for the optimization problem for a given modeltype. @@ -357,10 +379,11 @@ The values are not discounted. This function serve as fallback option if no other method is specified for a specific `modeltype`. """ -function objective(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) +function objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) # Declaration of the required subsets. 𝒩ⁿᵒᵗ = nodes_not_av(𝒩) + ℒᵒᵖᵉˣ = filter(has_opex, ℒ) 𝒫ᵉᵐ = filter(is_resource_emit, 𝒫) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) @@ -368,6 +391,10 @@ function objective(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) sum((m[:opex_var][n, t_inv] + m[:opex_fixed][n, t_inv]) for n ∈ 𝒩ⁿᵒᵗ) ) + link_opex = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], + sum((m[:link_opex_var][l, t_inv] + m[:link_opex_fixed][l, t_inv]) for l ∈ ℒᵒᵖᵉˣ) + ) + emissions = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], sum( m[:emissions_strategic][t_inv, p] * emission_price(modeltype, p, t_inv) for @@ -377,7 +404,9 @@ function objective(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) # Calculation of the objective function. @objective(m, Max, - -sum((opex[t_inv] + emissions[t_inv]) * duration_strat(t_inv) for t_inv ∈ 𝒯ᴵⁿᵛ) + -sum( + (opex[t_inv] + link_opex[t_inv] + emissions[t_inv]) * duration_strat(t_inv) + for t_inv ∈ 𝒯ᴵⁿᵛ) ) end diff --git a/src/structures/link.jl b/src/structures/link.jl index ef1b1c9..a689630 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -61,6 +61,16 @@ introduce links with associated emissions, *e.g.*, through leakage. """ has_emissions(l::Link) = false +""" + has_opex(l::Link) + +Checks whether link `l` has operational expenses. + +By default, links do not have operational expenses. You must dispatch on this function if +you want to introduce links with operational expenses. +""" +has_opex(l::Link) = false + """ link_res(l::Link) diff --git a/test/test_links.jl b/test/test_links.jl index a8fe165..5194421 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -1,4 +1,4 @@ -@testset "Link utilities" begin +@testset "Link - utilities" begin # Resources used in the analysis NG = ResourceEmit("NG", 0.2) @@ -61,6 +61,9 @@ # Test that all links do not have emissions @test !all(has_emissions(l) for l ∈ ℒ) + + # Test that all links do not have opex variables + @test !all(has_opex(l) for l ∈ ℒ) end @testset "Access functions" begin @@ -98,12 +101,54 @@ for l ∈ ℒ, t ∈ 𝒯 ) - # Test that `emissions_link` variable is empty + # Test that `emissions_link`, `link_opex_var`, and `link_opex_fixed` are empty @test isempty(m[:emissions_link]) + @test isempty(m[:link_opex_var]) + @test isempty(m[:link_opex_fixed]) end end -@testset "Link emissions" begin + +# Resources used in the analysis +Power = ResourceCarrier("Power", 0.0) +CO2 = ResourceEmit("CO2", 1.0) + +# Function for setting up the system +function link_graph(LinkType::Type{<:Link}) + # Used source, network, and sink + source = RefSource( + "source", + FixedProfile(4), + FixedProfile(10), + FixedProfile(0), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(100)), + Dict(Power => 1), + ) + + resources = [Power, CO2] + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(2, 2, ops; op_per_strat) + + nodes = [source, sink] + links = [ + LinkType(12, source, sink, Linear()) + ] + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => FixedProfile(0)), + CO2, + ) + case = Dict(:T => T, :nodes => nodes, :links => links, :products => resources) + return run_model(case, model, HiGHS.Optimizer), case, model +end + +@testset "Link - emissions" begin # Creation of a new link type with associated emissions in each operational period struct EmissionDirect <: Link id::Any @@ -112,7 +157,6 @@ end formulation::EMB.Formulation end function EMB.create_link(m, 𝒯, 𝒫, l::EmissionDirect, formulation::EMB.Formulation) - # Generic link in which each output corresponds to the input @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], m[:link_out][l, t, p] == m[:link_in][l, t, p] @@ -126,47 +170,8 @@ end end EMB.has_emissions(l::EmissionDirect) = true - # Resources used in the analysis - Power = ResourceCarrier("Power", 0.0) - CO2 = ResourceEmit("CO2", 1.0) - - # Function for setting up the system - function simple_graph() - # Used source, network, and sink - source = RefSource( - "source", - FixedProfile(4), - FixedProfile(10), - FixedProfile(0), - Dict(Power => 1), - ) - sink = RefSink( - "sink", - FixedProfile(3), - Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(100)), - Dict(Power => 1), - ) - - resources = [Power, CO2] - ops = SimpleTimes(5, 2) - op_per_strat = 10 - T = TwoLevel(2, 2, ops; op_per_strat) - - nodes = [source, sink] - links = [ - EmissionDirect(23, source, sink, Linear()) - ] - model = OperationalModel( - Dict(CO2 => FixedProfile(100)), - Dict(CO2 => FixedProfile(0)), - CO2, - ) - case = Dict(:T => T, :nodes => nodes, :links => links, :products => resources) - return case, model - end - - case, model = simple_graph() - m = run_model(case, model, HiGHS.Optimizer) + # Create and solve the system + m, case, model = link_graph(EmissionDirect) ℒ = case[:links] 𝒩 = case[:nodes] 𝒯 = case[:T] @@ -181,3 +186,43 @@ end for t ∈ 𝒯) @test all(value.(m[:emissions_total][t, CO2]) == 0.1 for t ∈ 𝒯) end + +@testset "Link - OPEX" begin + # Creation of a new link type with associated OPEX + struct OpexDirect <: Link + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + function EMB.create_link(m, 𝒯, 𝒫, l::OpexDirect, formulation::EMB.Formulation) + 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + # Generic link in which each output corresponds to the input + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] == m[:link_in][l, t, p] + ) + + # Variable OPEX calculation + @constraint(m, [t_inv ∈ 𝒯ᴵⁿᵛ], m[:link_opex_var][l, t_inv] == 0.2) + @constraint(m, [t_inv ∈ 𝒯ᴵⁿᵛ], m[:link_opex_fixed][l, t_inv] == 1) + end + EMB.has_opex(l::OpexDirect) = true + + # Create and solve the system + m, case, model = link_graph(OpexDirect) + ℒ = case[:links] + 𝒩 = case[:nodes] + 𝒯 = case[:T] + + # Test that `link_opex_var` and `link_opex_fixed` are not empty + @test !isempty(m[:link_opex_var]) + @test !isempty(m[:link_opex_fixed]) + + # Test that the values are included in the objective function + # 3 * 10 * 10 is the cost of the source Node + # 0.2 is the variable OPEX contribution from the link + # 1 is the fixed OPEX contribution from the link + # The multiplication with 2 * 2 is due to 2 strategic periods with a duration of 2 + @test objective_value(m) ≈ -((3 * 10 * 10) + 0.2 + 1) * (2 * 2) atol=TEST_ATOL +end From 9027a4de46b1cb3d6c2ad09044425e53def9fb38 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Sun, 27 Oct 2024 17:19:39 +0100 Subject: [PATCH 05/10] Added support for capacity for links - `constratins_capacity_installed` included for links - Function called from `create_link`, but not used - Requires the inclusion of a function `EMB.capacity(l, t)` for links --- docs/src/library/internals/functions.md | 1 + docs/src/library/public/links.md | 1 + docs/src/manual/optimization-variables.md | 14 +++++- src/EnergyModelsBase.jl | 2 +- src/constraint_functions.jl | 14 +++++- src/model.jl | 32 ++++++++++++-- src/structures/link.jl | 10 +++++ test/test_links.jl | 53 +++++++++++++++++++++-- 8 files changed, 117 insertions(+), 10 deletions(-) diff --git a/docs/src/library/internals/functions.md b/docs/src/library/internals/functions.md index 9a7f04e..ff8ad6e 100644 --- a/docs/src/library/internals/functions.md +++ b/docs/src/library/internals/functions.md @@ -38,6 +38,7 @@ variables_emission variables_flow variables_opex variables_nodes +variables_links_capacity variables_links_opex ``` diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index fe7a976..1b127b3 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -40,6 +40,7 @@ formulation The following functions are declared for filtering on `Link` types. ```@docs +has_capacity(l::Link) has_emissions(l::Link) has_opex(l::Link) is_unidirectional(l::Link) diff --git a/docs/src/manual/optimization-variables.md b/docs/src/manual/optimization-variables.md index 3dd5c62..ba59bd1 100644 --- a/docs/src/manual/optimization-variables.md +++ b/docs/src/manual/optimization-variables.md @@ -113,7 +113,19 @@ for t_inv ∈ 𝒯ᴵⁿᵛ, n ∈ 𝒩ˢᵘᵇ end ``` -The variables ``\texttt{cap\_inst}``, ``\texttt{stor\_charge\_inst}``, ``\texttt{stor\_level\_inst}``, and ``\texttt{stor\_discharge\_inst}`` are used in `EnergyModelsInvestment` to allow for investments in capacity of individual nodes. +We also introduce the potential for links with capacities. +By default, links do not introduce new variables. +The capacity variable is only created for a link ``l`` if the function [`has_capacity(n::Link)`](@ref) returns `true`. +The following link variable ise then declared representing the capacity of links: + +- ``\texttt{link\_cap\_inst}[l, t]``: Installed capacity of link ``l`` at operational period ``t``. + +!!! tip "Links with a capacity" + All links introduced in `EnergyModelsBase` do not allow for a capacity limiting the transfer. + If you plan to introduce a link with a capacity, you have to create a new method for the function `has_capacity` for your introduced link. + +!!! note "Inclusions of investments" + The variables ``\texttt{cap\_inst}``, ``\texttt{stor\_charge\_inst}``, ``\texttt{stor\_level\_inst}``, ``\texttt{stor\_discharge\_inst}``, and ``\texttt{link\_cap\_inst}`` are used in `EnergyModelsInvestment` to allow for investments in capacity of individual nodes. ## [Flow variables](@id man-opt_var-flow) diff --git a/src/EnergyModelsBase.jl b/src/EnergyModelsBase.jl index 7ce5b2c..a359bff 100644 --- a/src/EnergyModelsBase.jl +++ b/src/EnergyModelsBase.jl @@ -103,7 +103,7 @@ export has_charge, has_discharge export charge, level, discharge # Export commonly used functions for extracting fields in `Link` -export has_opex +export has_opex, has_capacity export formulation # Export commonly used functions for extracting fields in `EnergyModel` diff --git a/src/constraint_functions.jl b/src/constraint_functions.jl index cdba25b..a7a6e87 100644 --- a/src/constraint_functions.jl +++ b/src/constraint_functions.jl @@ -45,11 +45,12 @@ end """ constraints_capacity_installed(m, n::Node, 𝒯::TimeStructure, modeltype::EnergyModel) constraints_capacity_installed(m, n::Storage, 𝒯::TimeStructure, modeltype::EnergyModel) + constraints_capacity_installed(m, l::Link, 𝒯::TimeStructure, modeltype::EnergyModel) Function for creating the constraint on the installed capacity to the available capacity. These functions serve as fallback option if no other method is specified for a specific -`Node`. +`Node` or `Link`. !!! danger "Dispatching on this function" This function should only be used to dispatch on the modeltype for providing investments. @@ -89,6 +90,17 @@ function constraints_capacity_installed( end end end +function constraints_capacity_installed( + m, + l::Link, + 𝒯::TimeStructure, + modeltype::EnergyModel, +) + # Fix the installed capacity to the upper bound + for t ∈ 𝒯 + fix(m[:link_cap_inst][l, t], capacity(l, t); force = true) + end +end """ constraints_flow_in(m, n, 𝒯::TimeStructure, modeltype::EnergyModel) diff --git a/src/model.jl b/src/model.jl index eac1f8b..966e8c0 100644 --- a/src/model.jl +++ b/src/model.jl @@ -61,6 +61,7 @@ function create_model( variables_capacity(m, 𝒩, 𝒯, modeltype) variables_nodes(m, 𝒩, 𝒯, modeltype) + variables_links_capacity(m, ℒ, 𝒯, modeltype) variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype) # Construction of constraints for the problem @@ -234,6 +235,20 @@ Empty for operational models but required for multiple dispatch in investment mo """ function variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) end +""" + variables_links_capacity(m, ℒ, 𝒯, modeltype::EnergyModel) + +Declaration of the capacity variable for links (`:link_cap_inst`) in each operational period +t ∈ 𝒯 of the model. The capacity variabke is only created for links, if the function +[`has_capacity`](@ref) has received an additional method for a given link `l` returning the +value `true`. +""" +function variables_links_capacity(m, ℒ, 𝒯, modeltype::EnergyModel) + ℒᶜᵃᵖ = filter(has_capacity, ℒ) + + @variable(m, link_cap_inst[ℒᶜᵃᵖ, 𝒯]) +end + """ variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype::EnergyModel) @@ -413,11 +428,11 @@ end """ constraints_links(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) -Call the function `create_link` for link formulation +Call the function [`create_link`](@ref) for link formulation. """ function constraints_links(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) for l ∈ ℒ - create_link(m, 𝒯, 𝒫, l, formulation(l)) + create_link(m, 𝒯, 𝒫, l, modeltype, formulation(l)) end end @@ -584,15 +599,24 @@ function create_node(m, n::Availability, 𝒯, 𝒫, modeltype::EnergyModel) end """ - create_link(m, 𝒯, 𝒫, l, formulation::Formulation) + create_link(m, 𝒯, 𝒫, l::Link, formulation::Formulation) Set the constraints for a simple `Link` (input = output). Can serve as fallback option for all unspecified subtypes of `Link`. + +All links with capacity, as indicated through the function [`has_capacity`](@ref) call +furthermore the function [`constraints_capacity_installed`](@ref) for limiting the capacity +to the installed capacity. """ -function create_link(m, 𝒯, 𝒫, l, formulation::Formulation) +function create_link(m, 𝒯, 𝒫, l::Link, modeltype::EnergyModel, formulation::Formulation) # Generic link in which each output corresponds to the input @constraint(m, [t ∈ 𝒯, p ∈ link_res(l)], m[:link_out][l, t, p] == m[:link_in][l, t, p] ) + + # Call of the function for limiting the capacity to the maximum installed capacity + if has_capacity(l) + constraints_capacity_installed(m, l, 𝒯, modeltype) + end end diff --git a/src/structures/link.jl b/src/structures/link.jl index a689630..af50450 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -61,6 +61,16 @@ introduce links with associated emissions, *e.g.*, through leakage. """ has_emissions(l::Link) = false +""" + has_capacity(l::Link) + +Checks whether link `l` has a capacity. + +By default, links do not have a capacity. You must dispatch on this function if you want to +introduce links with capacities. +""" +has_capacity(l::Link) = false + """ has_opex(l::Link) diff --git a/test/test_links.jl b/test/test_links.jl index 5194421..2118309 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -101,10 +101,12 @@ for l ∈ ℒ, t ∈ 𝒯 ) - # Test that `emissions_link`, `link_opex_var`, and `link_opex_fixed` are empty + # Test that `emissions_link`, `link_opex_var`, `link_opex_fixed`, and `link_cap_inst` + #are empty @test isempty(m[:emissions_link]) @test isempty(m[:link_opex_var]) @test isempty(m[:link_opex_fixed]) + @test isempty(m[:link_cap_inst]) end end @@ -156,7 +158,7 @@ end to::EMB.Node formulation::EMB.Formulation end - function EMB.create_link(m, 𝒯, 𝒫, l::EmissionDirect, formulation::EMB.Formulation) + function EMB.create_link(m, 𝒯, 𝒫, l::EmissionDirect, modeltype::EnergyModel, formulation::EMB.Formulation) # Generic link in which each output corresponds to the input @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], m[:link_out][l, t, p] == m[:link_in][l, t, p] @@ -195,7 +197,7 @@ end to::EMB.Node formulation::EMB.Formulation end - function EMB.create_link(m, 𝒯, 𝒫, l::OpexDirect, formulation::EMB.Formulation) + function EMB.create_link(m, 𝒯, 𝒫, l::OpexDirect, modeltype::EnergyModel, formulation::EMB.Formulation) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) # Generic link in which each output corresponds to the input @@ -226,3 +228,48 @@ end # The multiplication with 2 * 2 is due to 2 strategic periods with a duration of 2 @test objective_value(m) ≈ -((3 * 10 * 10) + 0.2 + 1) * (2 * 2) atol=TEST_ATOL end + +@testset "Link - capacity" begin + # Creation of a new link type with associated capacity + struct CapDirect <: Link + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + function EMB.create_link(m, 𝒯, 𝒫, l::CapDirect, modeltype::EnergyModel, formulation::EMB.Formulation) + + # Generic link in which each output corresponds to the input + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] == m[:link_in][l, t, p] + ) + + # Capacity constraint + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] ≤ m[:link_cap_inst][l, t] + ) + constraints_capacity_installed(m, l, 𝒯, modeltype) + end + EMB.capacity(l::CapDirect, t) = OperationalProfile([2, 3, 3, 2, 1])[t] + EMB.has_capacity(l::CapDirect) = true + + # Create and solve the system + m, case, model = link_graph(CapDirect) + ℒ = case[:links] + 𝒩 = case[:nodes] + 𝒯 = case[:T] + + # Helper for usage + cap = OperationalProfile([2, 3, 3, 2, 1]) + deficit = OperationalProfile([1, 0, 0, 1, 2]) + + # Test that `link_cap_inst` is not empty + @test !isempty(m[:link_cap_inst]) + + # Test that the capacity is restricted, impacting the different nodes + @test all(value.(m[:cap_use][𝒩[1], t]) == cap[t] for t ∈ 𝒯) + @test all(value.(m[:cap_use][𝒩[2], t]) == cap[t] for t ∈ 𝒯) + @test all(value.(m[:sink_deficit][𝒩[2], t]) == deficit[t] for t ∈ 𝒯) + @test all(value.(m[:link_in][ℒ[1], t, Power]) == cap[t] for t ∈ 𝒯) + @test all(value.(m[:link_out][ℒ[1], t, Power]) == cap[t] for t ∈ 𝒯) +end From f8d959a71bbb7042d1c9c3c4717e4a7349d6b22a Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 28 Oct 2024 07:34:52 +0100 Subject: [PATCH 06/10] Allow for new variables for links --- docs/src/library/internals/functions.md | 1 + docs/src/library/public/functions.md | 1 + src/EnergyModelsBase.jl | 1 + src/model.jl | 55 ++++++++++++++++++++- test/test_links.jl | 64 +++++++++++++++++++++---- 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/src/library/internals/functions.md b/docs/src/library/internals/functions.md index ff8ad6e..4aa77a8 100644 --- a/docs/src/library/internals/functions.md +++ b/docs/src/library/internals/functions.md @@ -38,6 +38,7 @@ variables_emission variables_flow variables_opex variables_nodes +variables_links variables_links_capacity variables_links_opex ``` diff --git a/docs/src/library/public/functions.md b/docs/src/library/public/functions.md index 0953a72..357ae9a 100644 --- a/docs/src/library/public/functions.md +++ b/docs/src/library/public/functions.md @@ -32,6 +32,7 @@ See the page *[Creating a new node](@ref how_to-create_node)* for a detailed exp ```@docs variables_node create_node +variables_link ``` ## [Constraint functions](@id lib-pub-fun-con) diff --git a/src/EnergyModelsBase.jl b/src/EnergyModelsBase.jl index a359bff..341e22d 100644 --- a/src/EnergyModelsBase.jl +++ b/src/EnergyModelsBase.jl @@ -70,6 +70,7 @@ export InvData, InvDataStorage export @assert_or_log export create_model, run_model export variables_node, create_node +export variables_link export constraints_capacity, constraints_capacity_installed export constraints_flow_in, constraints_flow_out export constraints_level, constraints_level_aux diff --git a/src/model.jl b/src/model.jl index 966e8c0..b1a877e 100644 --- a/src/model.jl +++ b/src/model.jl @@ -63,6 +63,7 @@ function create_model( variables_links_capacity(m, ℒ, 𝒯, modeltype) variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype) + variables_links(m, ℒ, 𝒯, modeltype) # Construction of constraints for the problem constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) @@ -273,8 +274,9 @@ end Loop through all node types and create variables specific to each type. This is done by calling the method [`variables_node`](@ref) on all nodes of each type. -The node type representing the widest cathegory will be called first. That is, -`variables_node` will be called on a `Node` before it is called on `NetworkNode`-nodes. +The node type representing the widest category will be called first. That is, +[`variables_node`](@ref) will be called on a [`Node`](@ref EnergyModelsBase.Node) before it +is called on [`NetworkNode`](@ref)-nodes. """ function variables_nodes(m, 𝒩, 𝒯, modeltype::EnergyModel) # Vector of the unique node types in 𝒩. @@ -327,6 +329,55 @@ function variables_node(m, 𝒩ˢⁱⁿᵏ::Vector{<:Sink}, 𝒯, modeltype::Ene @variable(m, sink_deficit[𝒩ˢⁱⁿᵏ, 𝒯] >= 0) end +""" + variables_links(m, ℒ, 𝒯, modeltype::EnergyModel) + +Loop through all link types and create variables specific to each type. This is done by +calling the method [`variables_link`](@ref) on all links of each type. + +The link type representing the widest category will be called first. That is, +[`variables_link`](@ref) will be called on a [`Link`](@ref) before it is called on +[`Direct`](@ref)-links. +""" +function variables_links(m, ℒ, 𝒯, modeltype::EnergyModel) + # Vector of the unique link types in ℒ. + link_composite_types = unique(map(l -> typeof(l), ℒ)) + # Get all `link`-types in the type-hierarchy that the links ℒ represents. + link_types = collect_types(link_composite_types) + # Sort the link-types such that a supertype will always come its subtypes. + link_types = sort_types(link_types) + + for link_type ∈ link_types + # All links of the given sub type. + ℒˢᵘᵇ = filter(l -> isa(l, link_type), ℒ) + # Convert to a Vector of common-type instad of Any. + ℒˢᵘᵇ = convert(Vector{link_type}, ℒˢᵘᵇ) + try + variables_link(m, ℒˢᵘᵇ, 𝒯, modeltype) + catch e + # Parts of the exception message we are looking for. + pre1 = "An object of name" + pre2 = "is already attached to this model." + if isa(e, ErrorException) + if occursin(pre1, e.msg) && occursin(pre2, e.msg) + # 𝒩ˢᵘᵇ was already registered by a call to a supertype, so just continue. + continue + end + end + # If we make it to this point, this means some other error occured. This should + # not be ignored. + throw(e) + end + end +end + +""" + variables_link(m, ℒˢᵘᵇ::Vector{<:Link}, 𝒯, modeltype::EnergyModel) + +Default fallback method when no method is defined for a [`Link`](@ref) type. +""" +function variables_link(m, ℒˢᵘᵇ::Vector{<:Link}, 𝒯, modeltype::EnergyModel) end + """ constraints_node(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) diff --git a/test/test_links.jl b/test/test_links.jl index 2118309..4f213c3 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -110,13 +110,12 @@ end end - # Resources used in the analysis Power = ResourceCarrier("Power", 0.0) CO2 = ResourceEmit("CO2", 1.0) # Function for setting up the system -function link_graph(LinkType::Type{<:Link}) +function link_graph(LinkType::Vector{DataType}) # Used source, network, and sink source = RefSource( "source", @@ -138,9 +137,7 @@ function link_graph(LinkType::Type{<:Link}) T = TwoLevel(2, 2, ops; op_per_strat) nodes = [source, sink] - links = [ - LinkType(12, source, sink, Linear()) - ] + links = Link[link_type(string(link_type), source, sink, Linear()) for link_type ∈ LinkType] model = OperationalModel( Dict(CO2 => FixedProfile(100)), Dict(CO2 => FixedProfile(0)), @@ -173,7 +170,7 @@ end EMB.has_emissions(l::EmissionDirect) = true # Create and solve the system - m, case, model = link_graph(EmissionDirect) + m, case, model = link_graph([EmissionDirect]) ℒ = case[:links] 𝒩 = case[:nodes] 𝒯 = case[:T] @@ -212,7 +209,7 @@ end EMB.has_opex(l::OpexDirect) = true # Create and solve the system - m, case, model = link_graph(OpexDirect) + m, case, model = link_graph([OpexDirect]) ℒ = case[:links] 𝒩 = case[:nodes] 𝒯 = case[:T] @@ -254,7 +251,7 @@ end EMB.has_capacity(l::CapDirect) = true # Create and solve the system - m, case, model = link_graph(CapDirect) + m, case, model = link_graph([CapDirect]) ℒ = case[:links] 𝒩 = case[:nodes] 𝒯 = case[:T] @@ -273,3 +270,54 @@ end @test all(value.(m[:link_in][ℒ[1], t, Power]) == cap[t] for t ∈ 𝒯) @test all(value.(m[:link_out][ℒ[1], t, Power]) == cap[t] for t ∈ 𝒯) end + +@testset "Link - variable creation" begin + # Creation of a new link types + abstract type DirectSub <: Link end + struct DirectSub1 <: DirectSub + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + struct DirectSub2 <: DirectSub + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + + struct Direct1 <: Link + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + end + + function EMB.variables_link(m, ℒˢᵘᵇ::Vector{<:DirectSub}, 𝒯, modeltype::EnergyModel) + @variable(m, test_var_sub[ℒˢᵘᵇ, 𝒯]) + end + function EMB.variables_link(m, ℒˢᵘᵇ::Vector{<:DirectSub1}, 𝒯, modeltype::EnergyModel) + @variable(m, test_var_sub_1[ℒˢᵘᵇ, 𝒯]) + end + function EMB.variables_link(m, ℒˢᵘᵇ::Vector{<:Direct1}, 𝒯, modeltype::EnergyModel) + @variable(m, test_var_1[ℒˢᵘᵇ, 𝒯]) + end + + # Create and solve the system + m, case, model = link_graph([DirectSub1, DirectSub2, Direct1]) + ℒ = case[:links] + 𝒩 = case[:nodes] + 𝒯 = case[:T] + + # Test that `test_var_sub`, `test_var_sub_1`, and `test_var_1` are created + @test haskey(object_dictionary(m), :test_var_sub) + @test haskey(object_dictionary(m), :test_var_sub_1) + @test haskey(object_dictionary(m), :test_var_1) + + # Test that the variables are `test_var_sub`, `test_var_sub_1`, and `test_var_1` are + # created for the corresponding links + @test size(m[:test_var_sub]) == (2,10) + @test size(m[:test_var_sub_1]) == (1,10) + @test size(m[:test_var_1]) == (1,10) +end From 7c18c7ecdfb532723890b09e20d7ea41061e2990 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 28 Oct 2024 09:37:30 +0100 Subject: [PATCH 07/10] Incorporated the potential for investments in link - Tested for OPEX with InvestmentModel - Not directly incorporated into the model --- docs/src/library/internals/functions.md | 3 +- .../src/library/internals/reference_EMIExt.md | 3 +- docs/src/library/public/links.md | 3 +- ext/EMIExt/EMIExt.jl | 24 +++- ext/EMIExt/constraints.jl | 25 +++- ext/EMIExt/objective.jl | 13 +- ext/EMIExt/structures/inv_data.jl | 24 ++-- ext/EMIExt/variables_capex.jl | 78 +++++++---- src/EnergyModelsBase.jl | 2 +- src/model.jl | 16 ++- src/structures/link.jl | 9 ++ test/test_investments.jl | 122 ++++++++++++++++++ 12 files changed, 270 insertions(+), 52 deletions(-) diff --git a/docs/src/library/internals/functions.md b/docs/src/library/internals/functions.md index 4aa77a8..9a3e62b 100644 --- a/docs/src/library/internals/functions.md +++ b/docs/src/library/internals/functions.md @@ -33,7 +33,7 @@ constraints_level_bounds ```@docs variables_capacity -variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) +variables_capex(m, 𝒩, 𝒯, modeltype::EnergyModel) variables_emission variables_flow variables_opex @@ -41,6 +41,7 @@ variables_nodes variables_links variables_links_capacity variables_links_opex +variables_links_capex(m, ℒ, 𝒯, modeltype::EnergyModel) ``` ## Check functions diff --git a/docs/src/library/internals/reference_EMIExt.md b/docs/src/library/internals/reference_EMIExt.md index 1bb53b0..bcc25ce 100644 --- a/docs/src/library/internals/reference_EMIExt.md +++ b/docs/src/library/internals/reference_EMIExt.md @@ -32,7 +32,8 @@ check_inv_data ### Methods ```@docs -EMB.variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) +EMB.variables_capex(m, 𝒩, 𝒯, modeltype::AbstractInvestmentModel) +EMB.variables_links_capex(m, ℒ, 𝒯, modeltype::AbstractInvestmentModel) EMB.constraints_capacity_installed(m, n::EMB.Node, 𝒯::TimeStructure, modeltype::AbstractInvestmentModel) EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentModel) EMB.check_node_data(n::EMB.Node, data::InvestmentData, 𝒯, modeltype::AbstractInvestmentModel, check_timeprofiles::Bool) diff --git a/docs/src/library/public/links.md b/docs/src/library/public/links.md index 1b127b3..2a3acbd 100644 --- a/docs/src/library/public/links.md +++ b/docs/src/library/public/links.md @@ -25,7 +25,7 @@ Linear The following functions are declared for accessing fields from a `Link` type. -!!! warning +!!! warning "New link types" If you want to introduce new `Link` types, it is important that the function `formulation` is either functional for your new types or you have to declare a corresponding function. The first approach can be achieved through using the same name for the respective fields. @@ -33,6 +33,7 @@ The following functions are declared for accessing fields from a `Link` type. inputs(n::Link) outputs(n::Link) formulation +link_data ``` ## [Functions for identifying `Link`s](@id lib-pub-links-fun_identify) diff --git a/ext/EMIExt/EMIExt.jl b/ext/EMIExt/EMIExt.jl index ff6245e..55cee5e 100644 --- a/ext/EMIExt/EMIExt.jl +++ b/ext/EMIExt/EMIExt.jl @@ -30,6 +30,19 @@ function EMI.has_investment(n::EMB.Node) ) end +""" + EMI.has_investment(l::Link) + +For a given Link `l`, checks that it contains the required investment data. +""" +function EMI.has_investment(l::Link) + ( + has_capacity(l) && + hasproperty(l, :data) && + !isnothing(findfirst(data -> typeof(data) <: InvestmentData, link_data(l))) + ) +end + """ EMI.has_investment(n::Storage, field::Symbol) @@ -53,19 +66,26 @@ EMI.investment_data(inv_data::SingleInvData) = inv_data.cap """ EMI.investment_data(n::EMB.Node) + EMI.investment_data(l::Link) EMI.investment_data(n::EMB.Node, field::Symbol) + EMI.investment_data(l::Link, field::Symbol) -Return the `InvestmentData` of the Node `n` or if `field` is specified, it returns the -`InvData` for the corresponding capacity. +Return the `InvestmentData` of the Node `n` or Link `l`. +If `field` is specified, it returns the `InvData` for the corresponding capacity. """ EMI.investment_data(n::EMB.Node) = filter(data -> typeof(data) <: InvestmentData, node_data(n))[1] + EMI.investment_data(l::Link) = + filter(data -> typeof(data) <: InvestmentData, link_data(l))[1] EMI.investment_data(n::EMB.Node, field::Symbol) = getproperty(investment_data(n), field) +EMI.investment_data(l::Link, field::Symbol) = getproperty(investment_data(l), field) EMI.start_cap(n::EMB.Node, t_inv, inv_data::NoStartInvData, cap) = capacity(n, t_inv) EMI.start_cap(n::Storage, t_inv, inv_data::NoStartInvData, cap) = capacity(getproperty(n, cap), t_inv) +EMI.start_cap(l::Link, t_inv, inv_data::NoStartInvData, cap) = + capacity(l, t_inv) end diff --git a/ext/EMIExt/constraints.jl b/ext/EMIExt/constraints.jl index f769ca4..1956a80 100644 --- a/ext/EMIExt/constraints.jl +++ b/ext/EMIExt/constraints.jl @@ -1,14 +1,15 @@ """ constraints_capacity_installed(m, n::Node, 𝒯::TimeStructure, modeltype::AbstractInvestmentModel) constraints_capacity_installed(m, n::Storage, 𝒯::TimeStructure, modeltype::AbstractInvestmentModel) + constraints_capacity_installed(m, l::Link, 𝒯::TimeStructure, modeltype::AbstractInvestmentModel) When the modeltype is an investment model, the function introduces the related constraints for the capacity expansion. The investment mode and lifetime mode are used for adding constraints. The default function only accepts nodes with [`SingleInvData`](@ref). If you have several -capacities for investments, you have to dispatch specifically on the node type. This is -implemented for `Storage` nodes where the function introduces the related constraints for +capacities for investments, you have to dispatch specifically on the node or link type. This +is implemented for `Storage` nodes where the function introduces the related constraints for the capacity expansions for the fields `:charge`, `:level`, and `:discharge`. This requires the utilization of the [`StorageInvData`](@ref) investment type, in which the investment mode and lifetime mode are used for adding constraints for each capacity. @@ -66,3 +67,23 @@ function EMB.constraints_capacity_installed( end end end +function EMB.constraints_capacity_installed( + m, + l::Link, + 𝒯::TimeStructure, + modeltype::AbstractInvestmentModel, +) + if has_investment(l) + # Extract the investment data, the discount rate, and the strategic periods + disc_rate = discount_rate(modeltype) + inv_data = investment_data(l, :cap) + 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + # Add the investment constraints + EMI.add_investment_constraints(m, l, inv_data, :cap, :link_cap, 𝒯ᴵⁿᵛ, disc_rate) + else + for t ∈ 𝒯 + fix(m[:link_cap_inst][l, t], EMB.capacity(l, t); force = true) + end + end +end diff --git a/ext/EMIExt/objective.jl b/ext/EMIExt/objective.jl index 5c7791d..1c639b4 100644 --- a/ext/EMIExt/objective.jl +++ b/ext/EMIExt/objective.jl @@ -29,6 +29,7 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentMo # Filtering through the individual links ℒᵒᵖᵉˣ = filter(has_opex, ℒ) + ℒᴵⁿᵛ = filter(has_investment, ℒ) 𝒫ᵉᵐ = filter(EMB.is_resource_emit, 𝒫) # Emissions resources @@ -51,7 +52,7 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentMo ) ) - # Calculation of the capital cost contribution + # Calculation of the capital cost contributionof standard nodes capex_cap = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], sum(m[:cap_capex][n, t_inv] for n ∈ 𝒩ᴵⁿᵛ) ) @@ -63,12 +64,18 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentMo sum(m[:stor_discharge_capex][n, t_inv] for n ∈ 𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ) ) + # Calculation of the capital cost contribution of Links + capex_link = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], + sum(m[:link_cap_capex][l, t_inv] for l ∈ ℒᴵⁿᵛ) + ) + # Calculation of the objective function. @objective(m, Max, -sum( (opex[t_inv] + link_opex[t_inv] + emissions[t_inv]) * duration_strat(t_inv) * objective_weight(t_inv, disc; type = "avg") + - (capex_cap[t_inv] + capex_stor[t_inv]) * objective_weight(t_inv, disc) - for t_inv ∈ 𝒯ᴵⁿᵛ) + (capex_cap[t_inv] + capex_stor[t_inv] + capex_link[t_inv]) * + objective_weight(t_inv, disc) + for t_inv ∈ 𝒯ᴵⁿᵛ) ) end diff --git a/ext/EMIExt/structures/inv_data.jl b/ext/EMIExt/structures/inv_data.jl index 793ea07..03548a8 100644 --- a/ext/EMIExt/structures/inv_data.jl +++ b/ext/EMIExt/structures/inv_data.jl @@ -28,36 +28,36 @@ struct SingleInvData <: EMB.SingleInvData end EMB.SingleInvData(args...) = SingleInvData(args...) function EMB.SingleInvData( - capex_trans::TimeProfile, - trans_max_inst::TimeProfile, + capex::TimeProfile, + max_inst::TimeProfile, inv_mode::Investment, ) - return SingleInvData(NoStartInvData(capex_trans, trans_max_inst, inv_mode)) + return SingleInvData(NoStartInvData(capex, max_inst, inv_mode)) end function EMB.SingleInvData( - capex_trans::TimeProfile, - trans_max_inst::TimeProfile, + capex::TimeProfile, + max_inst::TimeProfile, inv_mode::Investment, life_mode::LifetimeMode, ) - return SingleInvData(NoStartInvData(capex_trans, trans_max_inst, inv_mode, life_mode)) + return SingleInvData(NoStartInvData(capex, max_inst, inv_mode, life_mode)) end function EMB.SingleInvData( - capex_trans::TimeProfile, - trans_max_inst::TimeProfile, + capex::TimeProfile, + max_inst::TimeProfile, initial::TimeProfile, inv_mode::Investment, ) - return SingleInvData(StartInvData(capex_trans, trans_max_inst, initial, inv_mode)) + return SingleInvData(StartInvData(capex, max_inst, initial, inv_mode)) end function EMB.SingleInvData( - capex_trans::TimeProfile, - trans_max_inst::TimeProfile, + capex::TimeProfile, + max_inst::TimeProfile, initial::TimeProfile, inv_mode::Investment, life_mode::LifetimeMode, ) return SingleInvData( - StartInvData(capex_trans, trans_max_inst, initial, inv_mode, life_mode), + StartInvData(capex, max_inst, initial, inv_mode, life_mode), ) end diff --git a/ext/EMIExt/variables_capex.jl b/ext/EMIExt/variables_capex.jl index 77e4651..dfac64a 100644 --- a/ext/EMIExt/variables_capex.jl +++ b/ext/EMIExt/variables_capex.jl @@ -1,5 +1,5 @@ """ - EMB.variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) + EMB.variables_capex(m, 𝒩, 𝒯, modeltype::AbstractInvestmentModel) Create variables for the capital costs for the investments in storage and technology nodes. @@ -27,7 +27,7 @@ Additional variables for investment in storage: * `:stor_charge_invest_b` - binary variable whether investments in rate are happening * `:stor_charge_remove_b` - binary variable whether investments in rate are removed """ -function EMB.variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentModel) +function EMB.variables_capex(m, 𝒩, 𝒯, modeltype::AbstractInvestmentModel) 𝒩ᴵⁿᵛ = filter(has_investment, filter(!EMB.is_storage, 𝒩)) 𝒩ˢᵗᵒʳ = filter(EMB.is_storage, 𝒩) 𝒩ˡᵉᵛᵉˡ = filter(n -> has_investment(n, :level), 𝒩ˢᵗᵒʳ) @@ -36,40 +36,66 @@ function EMB.variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::AbstractInvestmentM 𝒯ᴵⁿᵛ = strategic_periods(𝒯) # Add investment variables for reference nodes for each strategic period: - @variable(m, cap_capex[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, cap_current[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, cap_add[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, cap_rem[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, cap_invest_b[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) - @variable(m, cap_remove_b[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) + @variable(m, cap_capex[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, cap_current[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, cap_add[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, cap_rem[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, cap_invest_b[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) + @variable(m, cap_remove_b[𝒩ᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) # Add storage specific investment variables for each strategic period: - @variable(m, stor_level_capex[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_level_current[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_level_add[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_level_rem[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_level_invest_b[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) - @variable(m, stor_level_remove_b[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) + @variable(m, stor_level_capex[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_level_current[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_level_add[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_level_rem[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_level_invest_b[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) + @variable(m, stor_level_remove_b[𝒩ˡᵉᵛᵉˡ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) - @variable(m, stor_charge_capex[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_charge_current[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_charge_add[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_charge_rem[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_charge_invest_b[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) - @variable(m, stor_charge_remove_b[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0; container = IndexedVarArray) + @variable(m, stor_charge_capex[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_charge_current[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_charge_add[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_charge_rem[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_charge_invest_b[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) + @variable(m, stor_charge_remove_b[𝒩ᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) - @variable(m, stor_discharge_capex[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_discharge_current[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_discharge_add[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) - @variable(m, stor_discharge_rem[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0) + @variable(m, stor_discharge_capex[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_discharge_current[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_discharge_add[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, stor_discharge_rem[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0) @variable( m, - stor_discharge_invest_b[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0; + stor_discharge_invest_b[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray ) @variable( m, - stor_discharge_remove_b[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] >= 0; + stor_discharge_remove_b[𝒩ᵈⁱˢᶜʰᵃʳᵍᵉ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray ) end + +""" + EMB.variables_links_capex(m, ℒ, 𝒯, modeltype::AbstractInvestmentModel) + +Create variables for the capital costs for the investments in [`Link`](@ref)s. + +Additional variables for investment in capacity: +* `:link_cap_capex` - CAPEX costs for a technology +* `:link_cap_current` - installed capacity for storage in each strategic period +* `:link_cap_add` - added capacity +* `:link_cap_rem` - removed capacity +* `:link_cap_invest_b` - binary variable whether investments in capacity are happening +* `:link_cap_remove_b` - binary variable whether investments in capacity are removed +""" +function EMB.variables_links_capex(m, ℒ, 𝒯, modeltype::AbstractInvestmentModel) + ℒᴵⁿᵛ = filter(has_investment, ℒ) + 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + # Add investment variables for reference nodes for each strategic period: + @variable(m, link_cap_capex[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, link_cap_current[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, link_cap_add[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, link_cap_rem[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0) + @variable(m, link_cap_invest_b[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) + @variable(m, link_cap_remove_b[ℒᴵⁿᵛ, 𝒯ᴵⁿᵛ] ≥ 0; container = IndexedVarArray) +end diff --git a/src/EnergyModelsBase.jl b/src/EnergyModelsBase.jl index 341e22d..e74f5cc 100644 --- a/src/EnergyModelsBase.jl +++ b/src/EnergyModelsBase.jl @@ -105,7 +105,7 @@ export charge, level, discharge # Export commonly used functions for extracting fields in `Link` export has_opex, has_capacity -export formulation +export formulation, link_data # Export commonly used functions for extracting fields in `EnergyModel` export emission_limit, emission_price, co2_instance, discount_rate diff --git a/src/model.jl b/src/model.jl index b1a877e..df00b2e 100644 --- a/src/model.jl +++ b/src/model.jl @@ -57,11 +57,12 @@ function create_model( variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) variables_emission(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) variables_opex(m, 𝒩, 𝒯, 𝒫, modeltype) - variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype) + variables_capex(m, 𝒩, 𝒯, modeltype) variables_capacity(m, 𝒩, 𝒯, modeltype) variables_nodes(m, 𝒩, 𝒯, modeltype) variables_links_capacity(m, ℒ, 𝒯, modeltype) + variables_links_capex(m, ℒ, 𝒯, modeltype) variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype) variables_links(m, ℒ, 𝒯, modeltype) @@ -229,12 +230,12 @@ function variables_opex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) end """ - variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) + variables_capex(m, 𝒩, 𝒯, modeltype::EnergyModel) Declaration of the CAPEX variables of the model for each investment period `t_inv ∈ 𝒯ᴵⁿᵛ`. Empty for operational models but required for multiple dispatch in investment model. """ -function variables_capex(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel) end +function variables_capex(m, 𝒩, 𝒯, modeltype::EnergyModel) end """ variables_links_capacity(m, ℒ, 𝒯, modeltype::EnergyModel) @@ -268,6 +269,15 @@ function variables_links_opex(m, ℒ, 𝒯, 𝒫, modeltype::EnergyModel) @variable(m, link_opex_fixed[ℒᵒᵖᵉˣ, 𝒯ᴵⁿᵛ] ≥ 0) end +""" + variables_links_capex(m, ℒ, 𝒯, modeltype::EnergyModel) + +Declaration of the CAPEX variables of the model for links for each investment period +`t_inv ∈ 𝒯ᴵⁿᵛ`. +Empty for operational models but required for multiple dispatch in investment model. +""" +function variables_links_capex(m, ℒ, 𝒯, modeltype::EnergyModel) end + """ variables_nodes(m, 𝒩, 𝒯, modeltype::EnergyModel) diff --git a/src/structures/link.jl b/src/structures/link.jl index af50450..5097925 100644 --- a/src/structures/link.jl +++ b/src/structures/link.jl @@ -115,3 +115,12 @@ outputs(l::Link) = link_res(l) Return the formulation of a Link `l`. """ formulation(l::Link) = l.formulation + +""" + link_data(l::Link) + +Returns the [`Data`](@ref) array of link `l`. + +The default options returns nothing. +""" +link_data(l::Link) = nothing diff --git a/test/test_investments.jl b/test/test_investments.jl index abc76d0..c80e0f9 100644 --- a/test/test_investments.jl +++ b/test/test_investments.jl @@ -249,6 +249,128 @@ using EnergyModelsInvestments end +@testset "Link - OPEX and investments" begin + # Resources used in the analysis + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Creation of a new link type with associated capacity + struct InvDirect <: Link + id::Any + from::EMB.Node + to::EMB.Node + formulation::EMB.Formulation + data::Vector{<:Data} + end + function EMB.create_link(m, 𝒯, 𝒫, l::InvDirect, modeltype::EnergyModel, formulation::EMB.Formulation) + + # Generic link in which each output corresponds to the input + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] == m[:link_in][l, t, p] + ) + + # Capacity constraint + @constraint(m, [t ∈ 𝒯, p ∈ EMB.link_res(l)], + m[:link_out][l, t, p] ≤ m[:link_cap_inst][l, t] + ) + constraints_capacity_installed(m, l, 𝒯, modeltype) + end + EMB.capacity(l::InvDirect, t) = 0 + EMB.has_capacity(l::InvDirect) = true + EMB.link_data(l::InvDirect) = l.data + + # Create simple model + function link_inv_graph() + # Uses 2 sources, one cheap and one expensive + # The former is used for the OPEX calculations, the latter for investments + source_1 = RefSource( + "source_1", + FixedProfile(4), + StrategicProfile([10, 10, 1000, 1000]), + FixedProfile(0), + Dict(Power => 1), + ) + source_2 = RefSource( + "source_2", + FixedProfile(4), + StrategicProfile([1000, 1000, 10, 10]), + FixedProfile(0), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(100)), + Dict(Power => 1), + ) + + data_link = Data[ + SingleInvData( + FixedProfile(10), + FixedProfile(10), + ContinuousInvestment(FixedProfile(0), FixedProfile(10)), + ), + ] + + products = [Power, CO2] + nodes = [source_1, source_2, sink] + links = Link[ + OpexDirect("OpexDirect", source_1, sink, Linear()), + InvDirect("InvDirect", source_2, sink, Linear(), data_link), + ] + + # Creation of the time structure and global data + T = TwoLevel(4, 1, SimpleTimes(24, 1), op_per_strat = 24) + em_limits = Dict(CO2 => StrategicProfile([450, 400, 350, 300])) + em_cost = Dict(CO2 => FixedProfile(0)) + modeltype = InvestmentModel(em_limits, em_cost, CO2, 0.0) + + # WIP case structure + case = Dict(:nodes => nodes, :links => links, :products => products, :T => T) + return run_model(case, modeltype, HiGHS.Optimizer), case, modeltype + end + + m, case, model = link_inv_graph() + ℒ = case[:links] + 𝒩 = case[:nodes] + 𝒯 = case[:T] + 𝒯ᴵⁿᵛ = strategic_periods(𝒯) + + # Test for the total number of variables + @test size(all_variables(m))[1] == 1684 + + # Test that the values are included in the objective function + # cost_source_1, used in the first 2 sps + # 3 * 10 * 24 * 2 cap_use * opex_var * num_op * num_sp + # cost_source_2, used in the last 2 sps + # 3 * 10 * 24 * 2 cap_use * opex_var * num_op * num_sp + # cost_link_1, used in the first 2 sps + # (0.2 + 1) * 4 variable OPEX and fixed OPEX, hard coded, * num_sp + # cost_link_1, investment in third period + # 3 * 10 link_cap_add * capex(l) + cost_source_1 = 3 * 10 * 24 * 2 * 1 + cost_source_2 = 3 * 10 * 24 * 2 * 1 + cost_link_1 = (0.2 + 1) * 4 + cost_link_2 = 3 * 10 + + @test objective_value(m) ≈ -( + cost_source_1 + cost_source_2 + cost_link_1 + cost_link_2 + ) + + # Test that investments are happening + @test value.(m[:link_cap_add])[ℒ[2],𝒯ᴵⁿᵛ[3]] == 3 + + # Test that the variables are `link_cap_capex`, `link_cap_current`, `link_cap_add` and + # `link_cap_rem` are created for the corresponding links while `link_cap_invest_b` and + # `link_cap_remove_b` are empty + @test size(m[:link_cap_capex]) == (1, 4) + @test size(m[:link_cap_current]) == (1, 4) + @test size(m[:link_cap_add]) == (1, 4) + @test size(m[:link_cap_rem]) == (1, 4) + @test isempty(m[:link_cap_invest_b]) + @test isempty(m[:link_cap_remove_b]) +end + # Set the global to true to suppress the error message EMB.TEST_ENV = true From 979ff992658027e78b81defdf328be476970bea9 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 28 Oct 2024 13:27:23 +0100 Subject: [PATCH 08/10] Updated CI to LTS --- .github/workflows/ci.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87624c6..a14c1d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - # Since EnergyModelsBase doesn't have binary dependencies, + # Since EnergyModelsBase doesn't have binary dependencies, # only test on a subset of possible platforms. include: - version: '1' # The latest point-release (Linux) @@ -22,18 +22,18 @@ jobs: - version: '1' # The latest point-release (Windows) os: windows-latest arch: x64 - - version: '1.10' # 1.10 + - version: 'lts' # lts os: ubuntu-latest arch: x64 - - version: '1.10' # 1.10 - os: ubuntu-latest - arch: x86 + - version: 'lts' # lts + os: windows-latest + arch: x64 # - version: 'nightly' # os: ubuntu-latest # arch: x64 steps: - - uses: actions/checkout@v3 - - uses: julia-actions/setup-julia@v1 + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} @@ -50,5 +50,4 @@ jobs: - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 with: - depwarn: error - - uses: julia-actions/julia-processcoverage@v1 \ No newline at end of file + depwarn: error \ No newline at end of file From 175e02d5c0bfb0fa9b7c5302a18cd1fc6bb6c212 Mon Sep 17 00:00:00 2001 From: Julian Straus <104911227+JulStraus@users.noreply.github.com> Date: Mon, 4 Nov 2024 07:06:28 +0100 Subject: [PATCH 09/10] Apply suggestions from code review Co-authored-by: hellemo --- ext/EMIExt/objective.jl | 2 +- src/model.jl | 6 +++--- test/test_links.jl | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ext/EMIExt/objective.jl b/ext/EMIExt/objective.jl index 1c639b4..b74b6c3 100644 --- a/ext/EMIExt/objective.jl +++ b/ext/EMIExt/objective.jl @@ -52,7 +52,7 @@ function EMB.objective(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::AbstractInvestmentMo ) ) - # Calculation of the capital cost contributionof standard nodes + # Calculation of the capital cost contribution of standard nodes capex_cap = @expression(m, [t_inv ∈ 𝒯ᴵⁿᵛ], sum(m[:cap_capex][n, t_inv] for n ∈ 𝒩ᴵⁿᵛ) ) diff --git a/src/model.jl b/src/model.jl index df00b2e..9b0b6cb 100644 --- a/src/model.jl +++ b/src/model.jl @@ -149,7 +149,7 @@ end Declaration of the individual input (`:flow_in`) and output (`:flow_out`) flowrates for each technological node `n ∈ 𝒩` and link `l ∈ ℒ` (`:link_in` and `:link_out`). -By default, all nodes `𝒩` and links ℒ only allow for unidirectional flow. +By default, all nodes `𝒩` and links `ℒ` only allow for unidirectional flow. """ function variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) 𝒩ⁱⁿ = filter(has_input, 𝒩) @@ -161,7 +161,7 @@ function variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype::EnergyModel) @variable(m, link_in[l ∈ ℒ, 𝒯, inputs(l)]) @variable(m, link_out[l ∈ ℒ, 𝒯, outputs(l)]) - # Set the bounds fo unidirectional nodes and links + # Set the bounds for unidirectional nodes and links 𝒩ⁱⁿ⁻ᵘⁿⁱ = filter(is_unidirectional, 𝒩ⁱⁿ) 𝒩ᵒᵘᵗ⁻ᵘⁿⁱ = filter(is_unidirectional, 𝒩ᵒᵘᵗ) ℒᵘⁿⁱ = filter(is_unidirectional, ℒ) @@ -241,7 +241,7 @@ function variables_capex(m, 𝒩, 𝒯, modeltype::EnergyModel) end variables_links_capacity(m, ℒ, 𝒯, modeltype::EnergyModel) Declaration of the capacity variable for links (`:link_cap_inst`) in each operational period -t ∈ 𝒯 of the model. The capacity variabke is only created for links, if the function +t ∈ 𝒯 of the model. The capacity variable is only created for links, if the function [`has_capacity`](@ref) has received an additional method for a given link `l` returning the value `true`. """ diff --git a/test/test_links.jl b/test/test_links.jl index 4f213c3..9c86211 100644 --- a/test/test_links.jl +++ b/test/test_links.jl @@ -60,10 +60,10 @@ @test all(is_unidirectional(l) for l ∈ ℒ) # Test that all links do not have emissions - @test !all(has_emissions(l) for l ∈ ℒ) + @test !any(has_emissions(l) for l ∈ ℒ) # Test that all links do not have opex variables - @test !all(has_opex(l) for l ∈ ℒ) + @test !any(has_opex(l) for l ∈ ℒ) end @testset "Access functions" begin From 01e27767c22b252f65ea898a2ba780b8a689cf8c Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 4 Nov 2024 07:27:55 +0100 Subject: [PATCH 10/10] Incorporated minor updates and updated NEWS.md --- NEWS.md | 21 +++++++++++++++++++++ docs/src/how-to/contribute.md | 10 +++++----- ext/EMIExt/EMIExt.jl | 8 +++++--- src/model.jl | 5 +++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/NEWS.md b/NEWS.md index d162633..bc73290 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,26 @@ # Release notes +## Unversioned + +### Incorporation of bidirectional flow + +* Allow (in theory) for nodes and links with bidirectional flow through avoiding hard-coding a lower bound on flow variables. +* No existing links and nodes allow for bidirectional flow. +* Bidirectional flow requires new links and nodes with new methods for the function `is_unidirectional`. + +### Rework of links + +* Extended the functionality of links significantly. +* Allow for + * differing input and output resources of links as well as specifying these directly, + * emissions of links, + * OPEX of links (with both fixed and variable OPEX created at the same time), + * capacity of links, + * inclusion of specific link variables, and + * investments in links if the links have a capacity. +* The majority of changes are incorporated through filter functions and require the user to define new methods for the included functions (*i.e.*, `has_opex`, `has_emissions`, and `has_capacity`) +* Inclusion of variables follows principle of additional node variables. + ## Version 0.8.1 (2024-10-16) ### Bugfixes diff --git a/docs/src/how-to/contribute.md b/docs/src/how-to/contribute.md index 021a9b2..b746b89 100644 --- a/docs/src/how-to/contribute.md +++ b/docs/src/how-to/contribute.md @@ -7,7 +7,7 @@ Contributing to `EnergyModelsBase` can be achieved in several different ways. The main focus of `EnergyModelsBase` is to provide an easily extensible energy system optimization modelling framework. Hence, a first approach to contributing to `EnergyModelsBase` is to create a new package with, *e.g.*, the introduction of new node descriptions. -This is explained in [_How to create a new node_](@ref how_to-create_node). +This is explained in *[How to create a new node](@ref how_to-create_node)*. !!! tip If you are uncertain how you could incorporate new nodal descriptions, take a look at [`EnergyModelsRenewableProducers`](https://github.com/EnergyModelsX/EnergyModelsRenewableProducers.jl). @@ -90,8 +90,8 @@ It is in general preferable to work on a separate branch when developing new com Incorporate your changes in your new branch. The changes should be commented to understand the thought process behind them. -In addition, please provide new tests for the added functionality and be certain that the tests run. -The tests should be based on a minimum working example. +In addition, please provide new tests for the added functionality and be certain that the existing tests run. +New tests should be based on a minimum working example in which the new concept is evaluated. Some existing tests may potentially require changes when incorporating new features (especially within the test set `General tests`). In this case, it is ok that they are failing and we will comment on the required changes in the pull request. @@ -103,7 +103,7 @@ It is not necessary to provide changes directly in the documentation. It can be easier to include these changes after the pull request is accepted in principal. It is however a requirement to update the [`NEWS.md`](https://github.com/EnergyModelsX/EnergyModelsBase.jl/blob/main/NEWS.md) file under a new subheading titled "Unversioned". -!!! note +!!! note "Used style in EnergyModelsBase" Currently, we have not written a style guide for the framework. We follow in general the conventions of the *[Blue style guide](https://github.com/JuliaDiff/BlueStyle)* with minor modifications. @@ -116,4 +116,4 @@ Once you are satisified with your changes, create a pull request towards the mai We will internally assign the relevant person to the pull request. You may receive quite a few comments with respect to the incorporation and how it may potentially affect other parts of the code. -Please remaing patient as it may take potentially some time before we can respond to the changes, although we try to answer as fast as possible. +Please remain patient as it may take potentially some time before we can respond to the changes, although we try to answer as fast as possible. diff --git a/ext/EMIExt/EMIExt.jl b/ext/EMIExt/EMIExt.jl index 55cee5e..2965746 100644 --- a/ext/EMIExt/EMIExt.jl +++ b/ext/EMIExt/EMIExt.jl @@ -70,13 +70,15 @@ EMI.investment_data(inv_data::SingleInvData) = inv_data.cap EMI.investment_data(n::EMB.Node, field::Symbol) EMI.investment_data(l::Link, field::Symbol) -Return the `InvestmentData` of the Node `n` or Link `l`. +Return the `InvestmentData` of the Node `n` or Link `l`. It will return an error if the +if the Node `n` or Link `l` does not have investment data. + If `field` is specified, it returns the `InvData` for the corresponding capacity. """ EMI.investment_data(n::EMB.Node) = filter(data -> typeof(data) <: InvestmentData, node_data(n))[1] - EMI.investment_data(l::Link) = - filter(data -> typeof(data) <: InvestmentData, link_data(l))[1] +EMI.investment_data(l::Link) = + filter(data -> typeof(data) <: InvestmentData, link_data(l))[1] EMI.investment_data(n::EMB.Node, field::Symbol) = getproperty(investment_data(n), field) EMI.investment_data(l::Link, field::Symbol) = getproperty(investment_data(l), field) diff --git a/src/model.jl b/src/model.jl index 9b0b6cb..8b9af72 100644 --- a/src/model.jl +++ b/src/model.jl @@ -189,8 +189,9 @@ Declaration of emission variables per technology node with emissions `n ∈ 𝒩 emissions `l ∈ ℒᵉᵐ` for each emission resource `𝒫ᵉᵐ ∈ 𝒫`. The inclusion of node and link emissions require that the function `has_emissions` returns -a value `true` for the given node or link. This is by default achieved for nodes through -inclusion of `EmissionData`. +`true` for the given node or link. This is by default achieved for nodes through inclusion +of `EmissionData` in nodes while links require you to explicitly provide a method for your +link type. The emission variables are differentiated in: * `:emissions_node` - emissions of a node in an operational period,