diff --git a/NEWS.md b/NEWS.md index 09a66d9..a88581f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,10 +1,12 @@ # Release Notes -## Unversioned +## Version 0.8.0 (2026-04-13) * Adjusted to [`EnergyModelsBase` v0.10.0](https://github.com/EnergyModelsX/EnergyModelsBase.jl/releases/tag/v0.10.0): * Model worked without adjustments. * Adjustments required due to different behavior when investments are included due to the change in [`EnergyModelsInvestments` 0.9](https://github.com/EnergyModelsX/EnergyModelsInvestments.jl/releases/tag/v0.9.0). +* Removed requirement of `CO2Storage` to use `AccumulatingStrategic` to allow for a simpler application of the receding horizon framework. +* Reworked the test set of `CO2Storage`. ## Version 0.7.6 (2025-02-10) diff --git a/docs/src/nodes/storage.md b/docs/src/nodes/storage.md index d2687fb..cbb2ac6 100644 --- a/docs/src/nodes/storage.md +++ b/docs/src/nodes/storage.md @@ -13,9 +13,11 @@ It introduces a new *[storage behavior](@extref EnergyModelsBase lib-pub-nodes-s This storage behavior is called [`EnergyModelsCO2.AccumulatingStrategic`](@ref). !!! info "`StorageBehaviour` for `CO2Storage` nodes" - [`CO2Storage`](@ref) nodes utilize an inner constructor for specifying the storage behavior. - This means that they can only be created with [`EnergyModelsCO2.AccumulatingStrategic`](@ref). - If you plan to include a temporary CO₂ storage node, *e.g.* for storing captured CO₂ for subsequent utilization, it is best to utilize the [`RefStorage`](@extref EnergyModelsBase.RefStorage) node. + [`CO2Storage`](@ref) nodes utilize an outer constructor for specifying the storage behavior. + This means that they can be automatically created with [`EnergyModelsCO2.AccumulatingStrategic`](@ref). + If you plan to include a temporary CO₂ storage node, *e.g.* for storing captured CO₂ for subsequent utilization, it is best to utilize the [`RefStorage`](@extref EnergyModelsBase.RefStorage) node. + + The application of [`EnergyModelsRecedingHorizon`](https://github.com/EnergyModelsX/EnergyModelsRecedingHorizon.jl) requires you however to specify [`RecedingAccumulating`](https://github.com/EnergyModelsX/EnergyModelsRecedingHorizon.jl/blob/bc8832727c5df81f0f678618058bf524dc5d6987/src/structures/data.jl#L7). ### [Standard fields](@id nodes-co2_storage-fields-stand) @@ -48,7 +50,7 @@ The standard fields are given as: In the current version, it is only relevant for additional investment data when [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/) is used. !!! danger "The field `output`" - CO₂ storage nodes do not allow for the specification of an outlet field. + CO₂ storage nodes do not allow for the specification of an output field. Their sole intention is to serve as permanent storage nodes. Hence, although the field `output` is included in the composite type, it cannot be provided. diff --git a/src/datastructures.jl b/src/datastructures.jl index 3fcd67f..c8fd38d 100644 --- a/src/datastructures.jl +++ b/src/datastructures.jl @@ -51,12 +51,12 @@ increase during all strategic periods (sp), *i.e.*, the stored resource can not taken out of the storage. The initial storage level in a strategic period is set to the storage level at -the end of the prevoious sp. Note that the increased storage level during a sp +the end of the previous sp. Note that the increased storage level during a sp is multiplied with the length of the sp when the initial storage level for the next sp is set. This is achieved through the parametric input [`AccumulatingStrategic`](@ref). This input -is not a required input due to the utilization of an inner constructor. +is not a required input due to the utilization of an outer constructor. # Fields - **`id`** is the name/identifyer of the node. @@ -80,24 +80,22 @@ struct CO2Storage{T} <: Storage{T} stor_res::ResourceEmit input::Dict{<:Resource,<:Real} data::Array{<:Data} - - function CO2Storage( - id::Any, - charge::EMB.UnionCapacity, - level::EMB.UnionCapacity, - stor_res::ResourceEmit, - input::Dict{<:Resource,<:Real}, - data::Array{<:Data}, +end +function CO2Storage{T}( + id, + charge::EMB.UnionCapacity, + level::EMB.UnionCapacity, + stor_res::Resource, + input::Dict{<:Resource,<:Real}, +) where {T} + return CO2Storage{T}( + id, + charge, + level, + stor_res, + input, + Data[], ) - new{AccumulatingStrategic}( - id, - charge, - level, - stor_res, - input, - data - ) - end end function CO2Storage( id, @@ -105,14 +103,15 @@ function CO2Storage( level::EMB.UnionCapacity, stor_res::Resource, input::Dict{<:Resource,<:Real}, + data=Data[], ) - return CO2Storage( + return CO2Storage{AccumulatingStrategic}( id, charge, level, stor_res, input, - Data[], + data, ) end EMB.has_emissions(n::CO2Storage) = true diff --git a/test/test_co2storage.jl b/test/test_co2storage.jl index 082a587..8eefc62 100644 --- a/test/test_co2storage.jl +++ b/test/test_co2storage.jl @@ -2,11 +2,8 @@ power = ResourceCarrier("power", 1.0) CO2 = ResourceEmit("CO2", 1.0) -function small_graph(T; source_cap = 9) - products = [CO2, power] - - # Creation of a dictionary with entries of 0. for all resources - 𝒫₀ = Dict(k => 0 for k ∈ products) +function co2_stor_case(𝒯; source_cap = 9, stor_cap=20000) + 𝒫 = [CO2, power] co2_source = CO2Source( "source", @@ -26,136 +23,237 @@ function small_graph(T; source_cap = 9) co2_storage = CO2Storage( "storage", StorCapOpex(FixedProfile(10), FixedProfile(2), FixedProfile(1)), - StorCap(FixedProfile(20000)), + StorCap(FixedProfile(stor_cap)), CO2, Dict(CO2 => 1, power => 0.02), ) - nodes = [co2_source, co2_storage, el_source] - links = [ + 𝒩 = [co2_source, co2_storage, el_source] + ℒ = [ Direct("source_stor", co2_source, co2_storage), Direct("el_source_stor", el_source, co2_storage) ] modeltype = OperationalModel(Dict(CO2 => FixedProfile(3)), Dict(CO2 => FixedProfile(20)), CO2) - case = Case(T, products, [nodes, links], [[get_nodes, get_links]]) + case = Case(𝒯, 𝒫, [𝒩, ℒ]) return case, modeltype end -@testset "CO2 source and storage" begin - # Creation of the time structure - T = TwoLevel(2, 1, SimpleTimes(3, 2), op_per_strat = 6) +@testset "Constructor methods" begin + # Strictly speaking, not test set, but just to check that there are no errors + @test isa( + CO2Storage( + "storage", + StorCapOpex(FixedProfile(10), FixedProfile(2), FixedProfile(1)), + StorCap(FixedProfile(10)), + CO2, + Dict(CO2 => 1, power => 0.02), + ), + CO2Storage{EMC.AccumulatingStrategic}, + ) + @test isa( + CO2Storage( + "storage", + StorCapOpex(FixedProfile(10), FixedProfile(2), FixedProfile(1)), + StorCap(FixedProfile(10)), + CO2, + Dict(CO2 => 1, power => 0.02), + [EmissionsEnergy()], + ), + CO2Storage{EMC.AccumulatingStrategic}, + ) + @test isa( + CO2Storage{AccumulatingEmissions}( + "storage", + StorCapOpex(FixedProfile(10), FixedProfile(2), FixedProfile(1)), + StorCap(FixedProfile(10)), + CO2, + Dict(CO2 => 1, power => 0.02), + ), + CO2Storage{AccumulatingEmissions}, + ) + @test isa( + CO2Storage{AccumulatingEmissions}( + "storage", + StorCapOpex(FixedProfile(10), FixedProfile(2), FixedProfile(1)), + StorCap(FixedProfile(10)), + CO2, + Dict(CO2 => 1, power => 0.02), + [EmissionsEnergy()], + ), + CO2Storage{AccumulatingEmissions}, + ) +end - case, modeltype = small_graph(T) - m = EMB.run_model(case, modeltype, HiGHS.Optimizer) +@testset "Utility functions" begin + 𝒯 = TwoLevel(2, 1, SimpleTimes(3, 2), op_per_strat = 6) - nodes = get_nodes(case) - T = get_time_struct(case) + @testset "Utility - Check functions" begin + # Set the global to true to suppress the error message + EMB.TEST_ENV = true - source = nodes[1] - storage = nodes[2] + # Capacity violation + @test_throws AssertionError create_model(co2_stor_case(𝒯; stor_cap=-1000)...) - for (t_inv_prev, t_inv) ∈ withprev(strategic_periods(T)) - for (t_prev, t) ∈ withprev(t_inv) - if isnothing(t_prev) - if isnothing(t_inv_prev) - @test value(m[:stor_level][storage, t]) == - value(m[:flow_out][source, t, CO2]) * duration(t) - else - prev = last(t_inv_prev) + # Set the global to true to suppress the error message + EMB.TEST_ENV = false + end + + # Create the model and extract the parameters + case, modeltype = co2_stor_case(𝒯); + storage = get_nodes(case)[2] + + @testset "Utility - Identification functions" begin + # Test that all identification functions are working + @test EMB.is_storage(storage) + @test EMB.has_input(storage) + @test EMB.has_emissions(storage) + @test !EMB.has_output(storage) + @test EMB.has_charge(storage) + @test EMB.has_charge_cap(storage) + @test !EMB.has_discharge(storage) + end + + @testset "Utility - Extraction functions" begin + # Test the capacity extraction functions + @test isa(charge(storage), StorCapOpex) + @test capacity(charge(storage)) == FixedProfile(10) + @test all(capacity(charge(storage), t) == 10 for t ∈ 𝒯) + @test opex_var(charge(storage)) == FixedProfile(2) + @test all(opex_var(charge(storage), t) == 2 for t ∈ 𝒯) + @test opex_fixed(charge(storage)) == FixedProfile(1) + @test all(opex_fixed(charge(storage), t) == 1 for t ∈ 𝒯) + @test isa(level(storage), StorCap) + @test capacity(level(storage)) == FixedProfile(20000) + @test all(capacity(level(storage), t) == 20000 for t ∈ 𝒯) + + # Test the input extraction functions + @test inputs(storage) == [CO2, power] || inputs(storage) == [power, CO2] + @test inputs(storage, CO2) == 1 + @test inputs(storage, power) == 0.02 + + # Test additional extraction functions + @test storage_resource(storage) == CO2 + @test isempty(node_data(storage)) + end +end + +@testset "Constraint implementation" begin + @testset "CO2 source and storage" begin + # Creation of the time structure + T = TwoLevel(2, 1, SimpleTimes(3, 2), op_per_strat = 6) + + case, modeltype = co2_stor_case(T) + m = EMB.run_model(case, modeltype, HiGHS.Optimizer) + + 𝒩 = get_nodes(case) + T = get_time_struct(case) + + source = 𝒩[1] + storage = 𝒩[2] + + for (t_inv_prev, t_inv) ∈ withprev(strategic_periods(T)) + for (t_prev, t) ∈ withprev(t_inv) + if isnothing(t_prev) + if isnothing(t_inv_prev) + @test value(m[:stor_level][storage, t]) == + value(m[:flow_out][source, t, CO2]) * duration(t) + else + prev = last(t_inv_prev) - @test value(m[:stor_level][storage, prev]) + - value(m[:flow_out][source, t, CO2]) * duration(t) == - value(m[:stor_level][storage, t]) + @test value(m[:stor_level][storage, prev]) + + value(m[:flow_out][source, t, CO2]) * duration(t) == + value(m[:stor_level][storage, t]) + end + else + @test value(m[:stor_level][storage, t_prev]) + + value(m[:flow_out][source, t, CO2]) * duration(t) == + value(m[:stor_level][storage, t]) end - else - @test value(m[:stor_level][storage, t_prev]) + - value(m[:flow_out][source, t, CO2]) * duration(t) == - value(m[:stor_level][storage, t]) end end + + # Test that the source produces with max capacity in all operational periods. + source_cap = 9 + @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) end - # Test that the source produces with max capacity in all operational periods. - source_cap = 9 - @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) -end + @testset "Storage accumulation over strategic periods - SimpleTimes" begin + # Creation of the time structure + sp_length = 3 + op_length = 4 + T = TwoLevel(4, sp_length, SimpleTimes(op_length, 1); op_per_strat = op_length) + + source_cap = 9 + case, modeltype = co2_stor_case(T, source_cap = source_cap) + m = EMB.run_model(case, modeltype, HiGHS.Optimizer) + + 𝒩 = get_nodes(case) + T = get_time_struct(case) + + source = 𝒩[1] + storage = 𝒩[2] -@testset "Storage accumulation over strategic periods - SimpleTimes" begin - # Creation of the time structure - sp_length = 3 - op_length = 4 - T = TwoLevel(4, sp_length, SimpleTimes(op_length, 1); op_per_strat = op_length) - - source_cap = 9 - case, modeltype = small_graph(T, source_cap = source_cap) - m = EMB.run_model(case, modeltype, HiGHS.Optimizer) - - nodes = get_nodes(case) - T = get_time_struct(case) - - source = nodes[1] - storage = nodes[2] - - for (i, t_inv) ∈ enumerate(strategic_periods(T)) - for (t_prev, t) ∈ withprev(t_inv) - if isnothing(t_prev) - @test value(m[:stor_level][storage, t]) == - sp_length * op_length * source_cap * (i - 1) + source_cap - else - @test value(m[:stor_level][storage, t_prev]) + - value(m[:flow_out][source, t, CO2]) == - value(m[:stor_level][storage, t]) + for (i, t_inv) ∈ enumerate(strategic_periods(T)) + for (t_prev, t) ∈ withprev(t_inv) + if isnothing(t_prev) + @test value(m[:stor_level][storage, t]) == + sp_length * op_length * source_cap * (i - 1) + source_cap + else + @test value(m[:stor_level][storage, t_prev]) + + value(m[:flow_out][source, t, CO2]) == + value(m[:stor_level][storage, t]) + end end end + + # Test that the source produces with max capacity in all operational periods. + @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) end - # Test that the source produces with max capacity in all operational periods. - @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) -end + @testset "Storage accumulation over strategic periods - RepresentativePeriods" begin -@testset "Storage accumulation over strategic periods - RepresentativePeriods" begin - - # Creation of the time structure - op_1 = SimpleTimes(2, 2) - op_2 = SimpleTimes(2, 2) - sp_length = 3 - ops = RepresentativePeriods(2, 1, [0.5, 0.5], [op_1, op_2]) - op_length = length(ops) * 2 - T = TwoLevel(4, sp_length, ops; op_per_strat = op_length) - - source_cap = 9 - case, modeltype = small_graph(T, source_cap = source_cap) - m = EMB.run_model(case, modeltype, HiGHS.Optimizer) - - nodes = get_nodes(case) - T = get_time_struct(case) - - source = nodes[1] - storage = nodes[2] - - for (i, t_inv) ∈ enumerate(strategic_periods(T)) - 𝒯ʳᵖ = repr_periods(t_inv) - for (t_rp_prev, t_rp) ∈ withprev(𝒯ʳᵖ), (t_prev, t) ∈ withprev(t_rp) - if isnothing(t_rp_prev) && isnothing(t_prev) - @test value(m[:stor_level][storage, t]) == - sp_length * op_length * source_cap * (i - 1) + - source_cap * multiple_strat(t_inv, t) * duration(t) * probability(t) - - elseif isnothing(t_prev) - @test value(m[:stor_level][storage, t]) == - sp_length * op_length * source_cap * (i - 1) + - source_cap * 2 * 2 + - source_cap * multiple_strat(t_inv, t) * duration(t) * probability(t) - else - @test value(m[:stor_level][storage, t_prev]) + - value(m[:flow_out][source, t, CO2]) * duration(t) == - value(m[:stor_level][storage, t]) + # Creation of the time structure + op_1 = SimpleTimes(2, 2) + op_2 = SimpleTimes(2, 2) + sp_length = 3 + ops = RepresentativePeriods(2, 1, [0.5, 0.5], [op_1, op_2]) + op_length = length(ops) * 2 + T = TwoLevel(4, sp_length, ops; op_per_strat = op_length) + + source_cap = 9 + case, modeltype = co2_stor_case(T, source_cap = source_cap) + m = EMB.run_model(case, modeltype, HiGHS.Optimizer) + + 𝒩 = get_nodes(case) + T = get_time_struct(case) + + source = 𝒩[1] + storage = 𝒩[2] + + for (i, t_inv) ∈ enumerate(strategic_periods(T)) + 𝒯ʳᵖ = repr_periods(t_inv) + for (t_rp_prev, t_rp) ∈ withprev(𝒯ʳᵖ), (t_prev, t) ∈ withprev(t_rp) + if isnothing(t_rp_prev) && isnothing(t_prev) + @test value(m[:stor_level][storage, t]) == + sp_length * op_length * source_cap * (i - 1) + + source_cap * multiple_strat(t_inv, t) * duration(t) * probability(t) + + elseif isnothing(t_prev) + @test value(m[:stor_level][storage, t]) == + sp_length * op_length * source_cap * (i - 1) + + source_cap * 2 * 2 + + source_cap * multiple_strat(t_inv, t) * duration(t) * probability(t) + else + @test value(m[:stor_level][storage, t_prev]) + + value(m[:flow_out][source, t, CO2]) * duration(t) == + value(m[:stor_level][storage, t]) + end end end - end - # Test that the source produces with max capacity in all operational periods. - @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) + # Test that the source produces with max capacity in all operational periods. + @test all(value(m[:flow_out][source, t, CO2]) == source_cap for t ∈ T) + end end