From 3b52ee7beb8aa4a75fc26bd1bc74dc2fff59803b Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 17 Jun 2026 15:08:43 +0200 Subject: [PATCH 1/6] Included `StratPeriodDemandSink` - New sink based on `PeriodDemandSink` - Demand now specified per strategic period - Individual penalties for demand periods --- docs/src/library/internals/methods-fields.md | 16 +- docs/src/library/public.md | 3 +- src/EnergyModelsFlex.jl | 2 +- src/sink/constraint_functions.jl | 87 +++ src/sink/datastructures.jl | 148 ++++- src/sink/model.jl | 27 +- test/sink/test_PeriodDemandSink.jl | 556 ++++++++++++++----- 7 files changed, 689 insertions(+), 150 deletions(-) diff --git a/docs/src/library/internals/methods-fields.md b/docs/src/library/internals/methods-fields.md index 9d8a17e..28165e2 100644 --- a/docs/src/library/internals/methods-fields.md +++ b/docs/src/library/internals/methods-fields.md @@ -7,7 +7,7 @@ Pages = ["methods-fields.md"] ``` -## [`PeriodDemandSink` types](@id lib-int-met_field-PeriodDemandSink) +## [`PeriodDemandSink` node](@id lib-int-met_field-PeriodDemandSink) ```@docs EMF.period_demand @@ -16,13 +16,21 @@ EMF.periods(n::EMF.AbstractPeriodDemandSink, ts::TS.TimeStructure) EMF.number_of_periods ``` -## [`ActivationCostNode` types](@id lib-int-met_field-ActivationCostNode) +## [`StratPeriodDemandSink` node](@id lib-int-met_field-StratPeriodDemandSink) + +```@docs +EMF.strategic_demand +EMF.period_demand_min +EMF.period_demand_max +``` + +## [`ActivationCostNode` node](@id lib-int-met_field-ActivationCostNode) ```@docs EMF.activation_consumption ``` -## [`CapacityCostLink` types](@id lib-int-met_field-CapacityCostLink) +## [`CapacityCostLink` node](@id lib-int-met_field-CapacityCostLink) ```@docs EMF.cap_price @@ -31,7 +39,7 @@ EMF.periods(l::CapacityCostLink, ts::TS.TimeStructure) EMF.cap_resource ``` -## [`Combustion` types](@id lib-int-met_field-Combustion) +## [`Combustion` node](@id lib-int-met_field-Combustion) ```@docs EMF.limits diff --git a/docs/src/library/public.md b/docs/src/library/public.md index f58855d..c9ba0d3 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -12,6 +12,7 @@ The following sink node types are implemented in the `EnergyModelsFlex`: ```@docs PeriodDemandSink +StratPeriodDemandSink MultipleInputSink BinaryMultipleInputSinkStrat ContinuousMultipleInputSinkStrat @@ -20,7 +21,7 @@ LoadShiftingNode ## [Source `Node` types](@id lib-pub-source-node) -The following source node type is implemented in the `EnergyModelsFlex`: +The following source node type are implemented in the `EnergyModelsFlex`: ```@docs PayAsProducedPPA diff --git a/src/EnergyModelsFlex.jl b/src/EnergyModelsFlex.jl index 55c455d..7e7a39f 100644 --- a/src/EnergyModelsFlex.jl +++ b/src/EnergyModelsFlex.jl @@ -28,7 +28,7 @@ end include("legacy_constructors.jl") export MinUpDownTimeNode, ActivationCostNode, ElectricBattery, LoadShiftingNode -export PeriodDemandSink, MultipleInputSink +export PeriodDemandSink, StratPeriodDemandSink, MultipleInputSink export PayAsProducedPPA, StorageEfficiency, LimitedFlexibleInput, Combustion export ContinuousMultipleInputSinkStrat, BinaryMultipleInputSinkStrat export CapacityCostLink, FlexibleOutput, InflexibleSource diff --git a/src/sink/constraint_functions.jl b/src/sink/constraint_functions.jl index f0444b9..2bb5c70 100644 --- a/src/sink/constraint_functions.jl +++ b/src/sink/constraint_functions.jl @@ -64,6 +64,93 @@ function EMB.constraints_opex_var(m, n::AbstractPeriodDemandSink, 𝒯ᴡⁿᡛ, ) end +""" + EMB.constraints_capacity(m, n::StratPeriodDemandSink, 𝒯::TimeStructure, modeltype::EnergyModel) + +Function for creating the constraint on the maximum capacity utilization of an +[`StratPeriodDemandSink`](@ref). + +The method is changed from the standard approach through calculating both the strategic and +demand period surplus and deficit in addition to the operational period deficit. The +operational period surplus is fixed to 0 to avoid problems in the calculations. +""" +function EMB.constraints_capacity( + m, + n::StratPeriodDemandSink, + 𝒯::TimeStructure, + modeltype::EnergyModel, +) + # Declaration of the required subsets. + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + + @constraint( + m, + [t ∈ 𝒯], + m[:cap_use][n, t] + m[:sink_deficit][n, t] == m[:cap_inst][n, t] + ) + + # Fix the surplus to 0 + for t ∈ 𝒯 + fix(m[:sink_surplus][n, t], 0; force = true) + end + + # Provide the bounds for the partitions + @constraint( + m, + [t_inv ∈ 𝒯ᴡⁿᡛ, t_pd ∈ periods(n, t_inv)], + m[:demand_sink_deficit][n, t_pd] + + sum(m[:cap_use][n, t] * duration(t) for t ∈ t_pd) β‰₯ + period_demand_min(n, t_pd) * strategic_demand(n, t_inv) / + (multiple(first(t_pd)) / duration_strat(t_inv)) + ) + @constraint( + m, + [t_inv ∈ 𝒯ᴡⁿᡛ, t_pd ∈ periods(n, t_inv)], + sum(m[:cap_use][n, t] * duration(t) for t ∈ t_pd) ≀ + m[:demand_sink_surplus][n, t_pd] + + period_demand_max(n, t_pd) * strategic_demand(n, t_inv) / + (multiple(first(t_pd)) / duration_strat(t_inv)) + ) + + # Set the energy balance for the strategic period + @constraint( + m, + [t_inv ∈ 𝒯ᴡⁿᡛ,], + m[:demand_sink_strat_deficit][n, t_inv] + + sum(m[:cap_use][n, t] * scale_op_sp(t_inv, t) for t ∈ t_inv) == + m[:demand_sink_strat_surplus][n, t_inv] + strategic_demand(n, t_inv) + ) + + EMB.constraints_capacity_installed(m, n, 𝒯, modeltype) +end + +""" + EMB.constraints_opex_var(m, n::StratPeriodDemandSink, 𝒯ᴡⁿᡛ, ::EnergyModel) + +Function for creating the constraint on the variable OPEX of a [`StratPeriodDemandSink`](@ref). + +The method is adjusted from the default method through utilizing the strategic demand and +demand period surplus and deficit instead of the operational period surplus and deficit. +""" +function EMB.constraints_opex_var(m, n::StratPeriodDemandSink, 𝒯ᴡⁿᡛ, ::EnergyModel) + # Only penalise the total surplus and deficit in each period, not in the + # operational periods. + @constraint( + m, + [t_inv ∈ 𝒯ᴡⁿᡛ], + m[:opex_var][n, t_inv] == + m[:demand_sink_strat_surplus][n, t_inv] * surplus_penalty(n, t_inv) + + m[:demand_sink_strat_deficit][n, t_inv] * deficit_penalty(n, t_inv) + + sum( + ( + m[:demand_sink_surplus][n, t_pd] * surplus_penalty(n, t_pd) + + m[:demand_sink_deficit][n, t_pd] * deficit_penalty(n, t_pd) + ) * scale_op_sp(t_inv, first(t_pd)) / duration(first(t_pd)) + for t_pd ∈ periods(n, t_inv) + ) + ) +end + """ EMB.constraints_flow_in(m, n::MultipleInputSink, 𝒯::TimeStructure) diff --git a/src/sink/datastructures.jl b/src/sink/datastructures.jl index 9785f25..7d037ff 100644 --- a/src/sink/datastructures.jl +++ b/src/sink/datastructures.jl @@ -89,17 +89,6 @@ function PeriodDemandSink( return PeriodDemandSink(id, cap, period_duration, period_demand, penalty, input, ExtensionData[]) end -""" - period_demand(n::AbstractPeriodDemandSink) - period_demand(n::AbstractPeriodDemandSink, t_pd::TS.PeriodPartition) - -Returns the period demands of `AbstractPeriodDemandSink` `n` as a `TimeProfile` or in -demand period `t_pd`. -""" -period_demand(n::AbstractPeriodDemandSink) = n.period_demand -period_demand(n::AbstractPeriodDemandSink, t_pd::TS.PeriodPartition) = - n.period_demand[t_pd] - """ period_duration(n::AbstractPeriodDemandSink) @@ -126,6 +115,143 @@ Returns the number of demand periods for a `PeriodDemandSink` `n` for the given number_of_periods(n::AbstractPeriodDemandSink, ts::TS.TimeStructure) = length(periods(n, ts)) +""" + period_demand(n::AbstractPeriodDemandSink) + period_demand(n::AbstractPeriodDemandSink, t_pd::TS.PeriodPartition) + +Returns the period demands of `AbstractPeriodDemandSink` `n` as a `TimeProfile` or in +demand period `t_pd`. +""" +period_demand(n::AbstractPeriodDemandSink) = n.period_demand +period_demand(n::AbstractPeriodDemandSink, t_pd::TS.PeriodPartition) = + n.period_demand[t_pd] + +""" + struct StratPeriodDemandSink <: AbstractPeriodDemandSink + +A `StratPeriodDemandSink` is a [`Sink`](@extref EnergyModelsBase.Sink) that has a total +demand that can specified for each strategic period through the field `strat_demand`. In +addition, you can specify multiple demand periods, each with a minimum and maximum fraction +of the total demand that can be satisified within the demand period. + +# Fields +- **`id::Any`** is the name/identifier of the node. +- **`cap::TimeProfile`** is the installed capacity. +- **`strat_demand::TimeProfile`** is the demand within each strategic period that must be + satisfied. It **must** be specified as either a `FixedProfile` or `StrategicProfile` as + it is indexed over strategic periods +- **`period_duration::TimeProfile`** is the sum of the durations of the individual + operational periods within a given demand period. Due to a constructor, it can either be + specified as number (the same duration in all demand periods), as a vector (varying + duration of each demand period), or as a time profile (*e.g.*, varying period durations + due to varying operational time structures). It cannot be specified as `OperationalProfile`. +- **`period_min::TimeProfile`** is the relative fraction of the strategic demand that must + be at least satisifed in each demand period. +- **`period_max::TimeProfile`** is the relative fraction of the strategic demand that can at + most be satisifed in each demand period. +- **`penalty::Dict{Symbol,<:TimeProfile}`** are penalties for surplus or deficits. The + dictionary requires the fields `:surplus` and `:deficit`. The same penalty is utilized for + the strategic surplus/deficit and period surplus/deficit, al +- **`input::Dict{<:Resource,<:Real}`** are the input [`Resource`](@extref EnergyModelsBase.Resource)s + with conversion value `Real`. +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The + field `data` is conditional through usage of a constructor. +""" +struct StratPeriodDemandSink <: AbstractPeriodDemandSink + id::Any + cap::TimeProfile + strat_demand::TimeProfile + period_duration::TimeProfile + period_min::TimeProfile + period_max::TimeProfile + penalty::Dict{Symbol,<:TimeProfile} + input::Dict{<:Resource,<:Real} + data::Vector{<:ExtensionData} +end +function StratPeriodDemandSink( + id, + cap::TimeProfile, + strat_dem::TimeProfile, + period_duration::Union{Number, Vector{<:Number}}, + per_min::TimeProfile, + per_max::TimeProfile, + penalty::Dict{Symbol,<:TimeProfile}, + input::Dict{<:Resource,<:Real}, + data::Vector{<:ExtensionData}, +) + if isa(period_duration, Number) + per_dur = FixedProfile(period_duration) + elseif isa(period_duration, Vector{<:Number}) + per_dur = PartitionProfile(period_duration) + end + return StratPeriodDemandSink( + id, + cap, + strat_dem, + per_dur, + per_min, + per_max, + penalty, + input, + data, + ) +end +function StratPeriodDemandSink( + id, + cap::TimeProfile, + strat_demand::TimeProfile, + period_duration::Union{Number, Vector{<:Number}, TimeProfile}, + per_min::TimeProfile, + per_max::TimeProfile, + penalty::Dict{Symbol,<:TimeProfile}, + input::Dict{<:Resource,<:Real}, +) + return StratPeriodDemandSink( + id, + cap, + strat_demand, + period_duration, + per_min, + per_max, + penalty, + input, + ExtensionData[], + ) +end + +""" + strategic_demand(n::StratPeriodDemandSink) + strategic_demand(n::StratPeriodDemandSink, t_inv::TS.AbstractStrategicPeriod) + +Returns the strategic demands of `StratPeriodDemandSink` `n` as a `TimeProfile` or in +strategic period `t_inv`. +""" +strategic_demand(n::StratPeriodDemandSink) = n.strat_demand +strategic_demand(n::StratPeriodDemandSink, t_inv::TS.AbstractStrategicPeriod) = + n.strat_demand[t_inv] + +""" + period_demand_min(n::StratPeriodDemandSink) + period_demand_min(n::StratPeriodDemandSink, t_pd::TS.PeriodPartition) + +Returns the minimum period demands of `StratPeriodDemandSink` `n` as a `TimeProfile` or +in demand period `t_pd`. +""" +period_demand_min(n::StratPeriodDemandSink) = n.period_min +period_demand_min(n::StratPeriodDemandSink, t_pd::TS.PeriodPartition) = + n.period_min[t_pd] + +""" + period_demand_max(n::StratPeriodDemandSink) + period_demand_max(n::StratPeriodDemandSink, t_pd::TS.PeriodPartition) + +Returns the minimum period demands of `StratPeriodDemandSink` `n` as a `TimeProfile` or +in demand period `t_pd`. +""" +period_demand_max(n::StratPeriodDemandSink) = n.period_max +period_demand_max(n::StratPeriodDemandSink, t_pd::TS.PeriodPartition) = + n.period_max[t_pd] + """ struct MultipleInputSink <: AbstractMultipleInputSink diff --git a/src/sink/model.jl b/src/sink/model.jl index 7cdb35d..83b3abb 100644 --- a/src/sink/model.jl +++ b/src/sink/model.jl @@ -23,6 +23,32 @@ function EMB.variables_element( @variable(m, demand_sink_deficit[n ∈ 𝒩˒ⁱⁿᡏ, periods(n, 𝒯)] β‰₯ 0) end +""" + EMB.variables_element(m, 𝒩˒ⁱⁿᡏ::Vector{<:StratPeriodDemandSink}, 𝒯, ::EnergyModel) + +Creates the following additional variables for **ALL** [`StratPeriodDemandSink`](@ref) nodes: +- `demand_sink_strat_surplus[n, t_inv]` is a non-negative variable indicating a surplus in + demand in each strategic period `t_inv`. +- `demand_sink_strat_deficit[n, t_inv]` is a non-negative variable indicating a deficit in + demand in each strategic period `t_inv`. + +!!! note "Definition of period" + The period in the description above does not correspond to an operational period as known + from `TimeStruct`. Instead, it is a period in which the demand must be satisfied. A period + can consist of multiple operational periods. +""" +function EMB.variables_element( + m, + 𝒩˒ⁱⁿᡏ::Vector{<:StratPeriodDemandSink}, + 𝒯, + ::EnergyModel, +) + # Declaration of the required subsets. + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + + @variable(m, demand_sink_strat_surplus[𝒩˒ⁱⁿᡏ, 𝒯ᴡⁿᡛ] β‰₯ 0) + @variable(m, demand_sink_strat_deficit[𝒩˒ⁱⁿᡏ, 𝒯ᴡⁿᡛ] β‰₯ 0) +end """ EMB.variables_element(m, 𝒩::Vector{<:AbstractMultipleInputSinkStrat}, 𝒯, ::EnergyModel) @@ -40,7 +66,6 @@ function EMB.variables_element( 𝒯, ::EnergyModel, ) - # Declaration of the required subsets. 𝒯ᴡⁿᡛ = strategic_periods(𝒯) diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index 67b8d4f..d9ea24c 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -5,6 +5,7 @@ CO2 = ResourceEmit("COβ‚‚", 0) function per_dem_snk_case(; snk = nothing, repr = false, + type = PeriodDemandSink, 𝒯 = TwoLevel( 1, 1, SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7)), @@ -15,16 +16,17 @@ function per_dem_snk_case(; # The production can only run between 6-20 on weekdays, with capacity of 200. # No production on weekends. weekday_prod = vcat(zeros(3), ones(14)*200, [0]) - price_day = [1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 6.5, 6, 3.5] + day_1 = [1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 7.5, 9, 10, 9, 7, 6.5, 6, 3.5] + day_rest = [1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 6.5, 6, 3.5] # Demand for 1500 units per day, and nothing (0) in the weekend with a maximum production # of 200 per hour in between 6:00 and 20:00 if repr el_cost = RepresentativeProfile([ - OperationalProfile(price_day), - OperationalProfile(price_day), - OperationalProfile(price_day), - OperationalProfile(price_day), + OperationalProfile(day_1), + OperationalProfile(day_rest), + OperationalProfile(day_rest), + OperationalProfile(day_rest), FixedProfile(1e9), FixedProfile(0), FixedProfile(0), @@ -40,11 +42,15 @@ function per_dem_snk_case(; ]) per_dem = RepresentativeProfile([fill(1500, 5)..., 0, 0]) snk_sur = RepresentativeProfile(vcat([-8], zeros(6))) + period_min = RepresentativeProfile(vcat(ones(5)*.10, [0, 0])) + period_max = RepresentativeProfile(vcat(ones(5)*.25, [0, 0])) else - el_cost = OperationalProfile(vcat(repeat(price_day, 4), fill(1e9, 18), zeros(36))) + el_cost = OperationalProfile(vcat(day_1, repeat(day_rest, 3), fill(1e9, 18), zeros(36))) week_prod = OperationalProfile(vcat(repeat(weekday_prod, 5), zeros(36))) per_dem = PartitionProfile([fill(1500, 5)..., 0, 0]) snk_sur = PartitionProfile(vcat([-8], zeros(6))) + period_min = PartitionProfile(vcat(ones(5)*.10, [0, 0])) + period_max = PartitionProfile(vcat(ones(5)*.25, [0, 0])) end src = RefSource( @@ -56,14 +62,37 @@ function per_dem_snk_case(; ) if isnothing(snk) - snk = PeriodDemandSink( - "demand_product", - week_prod, - 24, - per_dem, - Dict(:surplus => snk_sur, :deficit => FixedProfile(1e4)), - Dict(Power => 1), - ) + if type == PeriodDemandSink + # Demand of 1500 units per day, and nothing (0) in the weekend + snk = PeriodDemandSink( + "demand_product", + week_prod, + 24, + per_dem, + Dict(:surplus => snk_sur, :deficit => FixedProfile(1e4)), + Dict(Power => 1), + ) + elseif type == StratPeriodDemandSink + # Equivalent annual demand for a demand of 1500 units per day, and nothing (0) + # in the weekend given by op_per_strat (the multiplier) divided by 24 (period_duration) + # and multiplied by 5/7 (5 of 7 days with production) and 1500 (daily demand) + strat_demand = 𝒯.op_per_strat / 24 * (5 / 7) * 1500 + # A minimum of 10 % is produced per day and a maximum of 25 % with no production + # on the weekend + snk = StratPeriodDemandSink( + "demand_product", + week_prod, + StrategicProfile(ones(length(strategic_periods(𝒯))) * strat_demand), + 24, + period_min, + period_max, + Dict( + :surplus => FixedProfile(0), + :deficit => FixedProfile(1e4), + ), + Dict(Power => 1), + ) + end end 𝒫 = [Power, CO2] @@ -80,66 +109,69 @@ function per_dem_snk_case(; return m, case, modeltype end -# Test that the fields of a `PeriodDemandSink` are correctly checked -# - EMB.check_node(n::PeriodDemandSink, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) @testset "Check functions" begin # Set the global to true to suppress the error message EMB.TEST_ENV = true - function check_per_dem_sink(; - cap = FixedProfile(10), - per_dur = 24, - per_dem = PartitionProfile([fill(1500, 5)..., 0, 0]), - penalty = Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), - input = Dict(Power => 1), - 𝒯 = TwoLevel(2, 1, SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7))), - ) - snk = PeriodDemandSink( - "demand_product", - cap, - per_dur, - per_dem, - penalty, - input, + # Test that the fields of a `PeriodDemandSink` are correctly checked + # - EMB.check_node(n::PeriodDemandSink, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) + @testset "Check - PeriodDemandSink" begin + function check_per_dem_sink(; + cap = FixedProfile(10), + per_dur = 24, + per_dem = PartitionProfile([fill(1500, 5)..., 0, 0]), + penalty = Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + input = Dict(Power => 1), + 𝒯 = TwoLevel(2, 1, SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7))), ) + snk = PeriodDemandSink( + "demand_product", + cap, + per_dur, + per_dem, + penalty, + input, + ) + + return per_dem_snk_case(; snk, 𝒯) + end - return per_dem_snk_case(; snk, 𝒯) - end - # Test that a wrong capacity is caught by the checks - @test_throws AssertionError check_per_dem_sink(; cap=FixedProfile(-25)) - - # Test that a wrong input is caught by the checks - @test_throws AssertionError check_per_dem_sink(; input = Dict(Power => -1)) - - # Test that a wrong penalty dictionary is caught - penalties = [ - Dict(:surplus => FixedProfile(0)), - Dict(:deficit => FixedProfile(0)), - Dict(:surplus => OperationalProfile([0]), :deficit => FixedProfile(1e4)), - Dict(:surplus => FixedProfile(0), :deficit => OperationalProfile([1e4])), - Dict(:surplus => FixedProfile(-1e5), :deficit => FixedProfile(1e4)), - ] - for penalty ∈ penalties - @test_throws AssertionError check_per_dem_sink(; penalty) - end + # Test that a wrong capacity is caught by the checks + @test_throws AssertionError check_per_dem_sink(; cap=FixedProfile(-25)) + + # Test that a wrong input is caught by the checks + @test_throws AssertionError check_per_dem_sink(; input = Dict(Power => -1)) + + # Test that a wrong penalty dictionary is caught + penalties = [ + Dict(:surplus => FixedProfile(0)), + Dict(:deficit => FixedProfile(0)), + Dict(:surplus => OperationalProfile([0]), :deficit => FixedProfile(1e4)), + Dict(:surplus => FixedProfile(0), :deficit => OperationalProfile([1e4])), + Dict(:surplus => FixedProfile(-1e5), :deficit => FixedProfile(1e4)), + ] + for penalty ∈ penalties + @test_throws AssertionError check_per_dem_sink(; penalty) + end - # Test that a wrong period duration is caught by the checks, including in other time - # structures - @test_throws AssertionError check_per_dem_sink(; per_dur=25) - @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24])) - week = SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7)) - opscen = OperationalScenarios(2, [week, week], [0.5, 0.5]) - 𝒯 = TwoLevel(2, 1, opscen; op_per_strat=8760.) - @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) - @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24]), 𝒯) - rep = RepresentativePeriods(2, 8760., [.5, .5], [week, week]) - 𝒯 = TwoLevel(2, 1, rep; op_per_strat=8760.) - @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) - @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24]), 𝒯) - - # Test that a wrong period demand is caught by the checks - @test_throws AssertionError check_per_dem_sink(; per_dem=OperationalProfile([25])) - @test_throws AssertionError check_per_dem_sink(; per_dem=FixedProfile(-10)) + # Test that a wrong period duration is caught by the checks, including in other time + # structures + @test_throws AssertionError check_per_dem_sink(; per_dur=25) + @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24])) + week = SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7)) + opscen = OperationalScenarios(2, [week, week], [0.5, 0.5]) + 𝒯 = TwoLevel(2, 1, opscen; op_per_strat=8760.) + @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) + @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24]), 𝒯) + rep = RepresentativePeriods(2, 8760., [.5, .5], [week, week]) + 𝒯 = TwoLevel(2, 1, rep; op_per_strat=8760.) + @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) + @test_throws AssertionError check_per_dem_sink(; per_dur=StrategicProfile([25, 24]), 𝒯) + + # Test that a wrong period demand is caught by the checks + @test_throws AssertionError check_per_dem_sink(; per_dem=OperationalProfile([25])) + @test_throws AssertionError check_per_dem_sink(; per_dem=FixedProfile(-10)) + end # Set the global again to false EMB.TEST_ENV = false @@ -158,36 +190,63 @@ end Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ) + strat_demand = FixedProfile(1500*5/168) + period_min = PartitionProfile([10, 10, 10, 10, 0, 0]) + period_max = PartitionProfile([25, 25, 25, 25, 0, 0]) + strat_snk = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + per_dur, + period_min, + period_max, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) + strat_demand = FixedProfile(1500*5/168) + period_min = PartitionProfile([10, 10, 10, 10, 0, 0]) + period_max = PartitionProfile([25, 25, 25, 25, 0, 0]) + strat_snk = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + per_dur, + period_min, + period_max, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) 𝒯 = TwoLevel(1, 1, SimpleTimes(7 * 24, 1)) π’―α΅–α΅ˆ = EMF.periods(snk, 𝒯) - @testset "Utility - constructor" begin - # Test that all constructor methods are working - snk_2 = PeriodDemandSink( - "demand_product", - cap, - per_dur, - per_dem, - Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), - Dict(Power => 0.5), - ExtensionData[] - ) - snk_3 = PeriodDemandSink( - "demand_product", - cap, - FixedProfile(per_dur), - per_dem, - Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), - Dict(Power => 0.5), - ) - snk_4 = PeriodDemandSink( - "demand_product", - cap, - PartitionProfile(ones(7)*24), - per_dem, - Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), - Dict(Power => 0.5), - ) + @testset "Utility - Constructor" begin + @testset "Constructor - PeriodDemandSink" begin + # Test that all constructor methods are working + snk_2 = PeriodDemandSink( + "demand_product", + cap, + per_dur, + per_dem, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ExtensionData[] + ) + snk_3 = PeriodDemandSink( + "demand_product", + cap, + FixedProfile(per_dur), + per_dem, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) + snk_4 = PeriodDemandSink( + "demand_product", + cap, + PartitionProfile(ones(7)*24), + per_dem, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) snk_5 = PeriodDemandSink( "demand_product", per_dur, @@ -207,67 +266,160 @@ end ExtensionData[] ) - for field ∈ fieldnames(PeriodDemandSink) - if field β‰  :period_duration && field β‰  :period_demand - @test getproperty(snk, field) == getproperty(snk_2, field) - @test getproperty(snk, field) == getproperty(snk_3, field) - @test getproperty(snk, field) == getproperty(snk_4, field) + for field ∈ fieldnames(PeriodDemandSink) + if field β‰  :period_duration && field β‰  :period_demand + @test getproperty(snk, field) == getproperty(snk_2, field) + @test getproperty(snk, field) == getproperty(snk_3, field) + @test getproperty(snk, field) == getproperty(snk_4, field) @test getproperty(snk, field) == getproperty(snk_5, field) @test getproperty(snk, field) == getproperty(snk_6, field) - else - @test all( - getproperty(snk, field)[t_pd] == getproperty(snk_2, field)[t_pd] - for t_pd ∈ π’―α΅–α΅ˆ) - @test all( - getproperty(snk, field)[t_pd] == getproperty(snk_3, field)[t_pd] - for t_pd ∈ π’―α΅–α΅ˆ) - @test all( - getproperty(snk, field)[t_pd] == getproperty(snk_4, field)[t_pd] - for t_pd ∈ π’―α΅–α΅ˆ) + else + @test all( + getproperty(snk, field)[t_pd] == getproperty(snk_2, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + @test all( + getproperty(snk, field)[t_pd] == getproperty(snk_3, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + @test all( + getproperty(snk, field)[t_pd] == getproperty(snk_4, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) @test all( getproperty(snk, field)[t_pd] == getproperty(snk_5, field)[t_pd] for t_pd ∈ π’―α΅–α΅ˆ) @test all( getproperty(snk, field)[t_pd] == getproperty(snk_6, field)[t_pd] for t_pd ∈ π’―α΅–α΅ˆ) + end + end + end + @testset "Constructor - StratPeriodDemandSink" begin + # Test that all constructor methods are working + strat_snk_2 = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + per_dur, + period_min, + period_max, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ExtensionData[] + ) + strat_snk_3 = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + FixedProfile(per_dur), + period_min, + period_max, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) + strat_snk_4 = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + PartitionProfile(ones(7)*24), + period_min, + period_max, + Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + Dict(Power => 0.5), + ) + + for field ∈ fieldnames(StratPeriodDemandSink) + if field β‰  :period_duration + @test getproperty(strat_snk, field) == getproperty(strat_snk_2, field) + @test getproperty(strat_snk, field) == getproperty(strat_snk_3, field) + @test getproperty(strat_snk, field) == getproperty(strat_snk_4, field) + else + @test all( + getproperty(strat_snk, field)[t_pd] == + getproperty(strat_snk_2, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + @test all( + getproperty(strat_snk, field)[t_pd] == + getproperty(strat_snk_3, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + @test all( + getproperty(strat_snk, field)[t_pd] == + getproperty(strat_snk_4, field)[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + end end end end @testset "Utility - Identification functions" begin # Test that all identification functions are working - @test EMB.has_input(snk) - @test !EMB.has_emissions(snk) - @test !EMB.has_output(snk) + @testset "Identification - PeriodDemandSink" begin + @test EMB.has_input(snk) + @test !EMB.has_emissions(snk) + @test !EMB.has_output(snk) + end + @testset "Identification - StratPeriodDemandSink" begin + @test EMB.has_input(strat_snk) + @test !EMB.has_emissions(strat_snk) + @test !EMB.has_output(strat_snk) + end end @testset "Utility - Extraction functions" begin # Test that all EMB extraction functions are working - @test capacity(snk) == FixedProfile(10) - @test all(capacity(snk, t) == 10 for t ∈ 𝒯) - @test inputs(snk) == [Power] - @test inputs(snk, Power) == 0.5 - @test surplus_penalty(snk) == FixedProfile(0) - @test all(surplus_penalty(snk, t) == 0 for t ∈ 𝒯) - @test deficit_penalty(snk) == FixedProfile(1e4) - @test all(deficit_penalty(snk, t) == 1e4 for t ∈ 𝒯) - @test node_data(snk) == ExtensionData[] - - # Test that all EMF extraction functions are working - @test EMF.period_demand(snk) == per_dem - @test EMF.periods(snk, 𝒯) == partition_duration(𝒯, per_dur) - @test all( - EMF.period_demand(snk, t_dp) == per_dem[t_dp] for t_dp ∈ EMF.periods(snk, 𝒯) - ) + @testset "Extraction - PeriodDemandSink" begin + @test capacity(snk) == FixedProfile(10) + @test all(capacity(snk, t) == 10 for t ∈ 𝒯) + @test inputs(snk) == [Power] + @test inputs(snk, Power) == 0.5 + @test surplus_penalty(snk) == FixedProfile(0) + @test all(surplus_penalty(snk, t) == 0 for t ∈ 𝒯) + @test deficit_penalty(snk) == FixedProfile(1e4) + @test all(deficit_penalty(snk, t) == 1e4 for t ∈ 𝒯) + @test node_data(snk) == ExtensionData[] + + # Test that all EMF extraction functions are working + @test EMF.periods(snk, 𝒯) == partition_duration(𝒯, per_dur) + @test EMF.period_demand(snk) == per_dem + @test all( + EMF.period_demand(snk, t_dp) == per_dem[t_dp] + for t_dp ∈ EMF.periods(snk, 𝒯)) + end + @testset "Extraction - StratPeriodDemandSink" begin + @test capacity(snk) == FixedProfile(10) + @test all(capacity(strat_snk, t) == 10 for t ∈ 𝒯) + @test inputs(strat_snk) == [Power] + @test inputs(strat_snk, Power) == 0.5 + @test surplus_penalty(strat_snk) == FixedProfile(0) + @test all(surplus_penalty(strat_snk, t) == 0 for t ∈ 𝒯) + @test deficit_penalty(strat_snk) == FixedProfile(1e4) + @test all(deficit_penalty(strat_snk, t) == 1e4 for t ∈ 𝒯) + @test node_data(strat_snk) == ExtensionData[] + + # Test that all EMF extraction functions are working + @test EMF.periods(strat_snk, 𝒯) == partition_duration(𝒯, per_dur) + @test_throws FieldError EMF.period_demand(strat_snk) + @test EMF.strategic_demand(strat_snk) == strat_demand + @test all( + EMF.strategic_demand(strat_snk, t_inv) == strat_demand[t_inv] + for t_inv ∈ strategic_periods(𝒯)) + @test EMF.period_demand_min(strat_snk) == period_min + @test all( + EMF.period_demand_min(strat_snk, t_dp) == period_min[t_dp] + for t_dp ∈ EMF.periods(strat_snk, 𝒯)) + @test EMF.period_demand_max(strat_snk) == period_max + @test all( + EMF.period_demand_max(strat_snk, t_dp) == period_max[t_dp] + for t_dp ∈ EMF.periods(strat_snk, 𝒯)) + end end @testset "Utility - Other functions" begin # Test that all other functions required for a PeriodDemandSink are working @test EMF.number_of_periods(snk, 𝒯) == 7 + @test EMF.number_of_periods(strat_snk, 𝒯) == 7 end end -@testset "Constraint implementation" begin +@testset "Constraint implementation - PeriodDemandSink" begin # Create a test set for testing the invariants function per_sink_tests(m, case; repr=false, oscs=1) @@ -284,7 +436,7 @@ end snk = get_nodes(case)[2] pers = EMF.periods(snk, 𝒯) - # Adjust the variables based on the functions + # Adjust the variables based on the chosne time structure if repr main_day = OperationalProfile(vcat(zeros(3), ones(6)*200, zeros(6), [100, 200], [0])) prod = RepresentativeProfile(vcat( @@ -380,6 +532,146 @@ end m, case, modeltype = per_dem_snk_case(; 𝒯, repr=true) obj_3 = per_sink_tests(m, case; repr=true) - @test obj_1 β‰ˆ obj_2 - @test obj_1 β‰ˆ obj_3 + @test obj_1 β‰ˆ obj_2 rtol=1e-4 + @test obj_1 β‰ˆ obj_3 rtol=1e-4 +end + +@testset "Constraint implementation - StratartPeriodDemandSink" begin + # Create a test set for testing the invariants + function strat_per_sink_test(m, case; repr=false, oscs=1) + set_optimizer(m, OPTIMIZER) + optimize!(m) + + # Test optimal solution + general_tests(m) + + # Extract the required values from the case and node + 𝒯 = get_time_struct(case) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + t_inv = first(𝒯ᴡⁿᡛ) + snk = get_nodes(case)[2] + π’―α΅–α΅ˆ = EMF.periods(snk, 𝒯) + + # Test the variable generation + @test length(m[:demand_sink_surplus][snk, :]) == 14 * oscs + @test length(m[:demand_sink_deficit][snk, :]) == 14 * oscs + @test length(m[:demand_sink_strat_surplus][snk, :]) == 2 + @test length(m[:demand_sink_strat_deficit][snk, :]) == 2 + + # Tests for the capacity function + # EMB.constraints_capacity(m, n::AbstractPeriodDemandSink, 𝒯::TimeStructure, modeltype::EnergyModel) + + # Adjust the variables based on the chosne time structure + dem = EMF.strategic_demand(snk,t_inv)/(365/7) + if repr + part_prod = RepresentativeProfile([2100, 1800, 1800, 1800, 0, 0, 0]) + part_deficit = RepresentativeProfile([0, 0, 0, 0, 0.1*dem, 0, 0]) + part_surplus = RepresentativeProfile([2100-0.25*dem, 0, 0, 0, 0, 0, 0]) + + else + part_prod = PartitionProfile([2100, 1800, 1800, 1800, 0, 0, 0]) + part_deficit = PartitionProfile([0, 0, 0, 0, 0.1*dem, 0, 0]) + part_surplus = PartitionProfile([2100-0.25*dem, 0, 0, 0, 0, 0, 0]) + end + + # Test that the individual deficits and surpluses are correctly calculated + @test all( + value.(m[:sink_deficit][snk, t]) + value.(m[:cap_use][snk, t]) β‰ˆ + value.(m[:cap_inst][snk, t]) for t ∈ 𝒯, + atol = TEST_ATOL + ) + # Test that the surplus is fixed to 0 + @test all(is_fixed.(m[:sink_surplus][snk, t]) for t ∈ 𝒯) + @test all(value.(m[:sink_surplus][snk, t]) β‰ˆ 0 for t ∈ 𝒯) + + # Test that the period balances are satisfied + # 7*24 corresponds to the total duration of all operational period within a strategic + # period 8760/(7*24) is correspondingly the number of repetitions of each operational + # period + fraction_parts = StrategicProfile([7*24 * (8760/(7*24)), 7*24 * (8760/(7*24))]) + @test all( + value.(m[:demand_sink_deficit][snk, t_pd]) + + sum(value.(m[:cap_use][snk, t]) * duration(t) for t ∈ t_pd) ≳ + EMF.period_demand_min(snk, t_pd) * EMF.strategic_demand(snk, t_inv) * + sum(duration(t) for t ∈ t_pd) / fraction_parts[t_inv] + for t_inv ∈ 𝒯ᴡⁿᡛ, t_pd ∈ EMF.periods(snk, t_inv)) + @test all( + sum(value.(m[:cap_use][snk, t]) * duration(t) for t ∈ t_pd) ≲ + value.(m[:demand_sink_surplus][snk, t_pd]) + + EMF.period_demand_max(snk, t_pd) * EMF.strategic_demand(snk, t_inv) * + fraction_parts[t_inv] / sum(duration(t) for t ∈ t_pd) + for t_inv ∈ 𝒯ᴡⁿᡛ, t_pd ∈ EMF.periods(snk, t_inv)) + + # Test that the deficit is equal to specified profile + @test all(value.(m[:demand_sink_deficit][snk, t_pd]) β‰ˆ part_deficit[t_pd] for t_pd ∈ π’―α΅–α΅ˆ) + + if oscs == 1 + # Test that the surplus is equal to specified profile + @test all(value.(m[:demand_sink_surplus][snk, t_pd]) β‰ˆ part_surplus[t_pd] for t_pd ∈ π’―α΅–α΅ˆ) + + # Test that demands are equal to the production profile + @test all( + sum(value.(m[:cap_use][snk, t]) * duration(t) for t ∈ t_pd) β‰ˆ part_prod[t_pd] + for t_pd ∈ π’―α΅–α΅ˆ) + end + + # Test that the annual balance is satisfied and the penalties are as expected + @test all( + sum(value.(m[:cap_use][snk, t]) * scale_op_sp(t_inv, t) for t ∈ t_inv) + + value.(m[:demand_sink_strat_deficit][snk, t_inv]) β‰ˆ + value.(m[:demand_sink_strat_surplus][snk, t_inv]) + EMF.strategic_demand(snk, t_inv) + for t_inv ∈ 𝒯ᴡⁿᡛ) + @test all(value.(m[:demand_sink_strat_deficit][snk, t_inv]) β‰ˆ 0 for t_inv ∈ 𝒯ᴡⁿᡛ) + @test all(value.(m[:demand_sink_strat_surplus][snk, t_inv]) β‰ˆ 0 for t_inv ∈ 𝒯ᴡⁿᡛ) + + # Test the upper bound on the installed capacity and the value for the capacity + @test all(value.(m[:cap_use][snk, t]) ≲ value.(m[:cap_inst][snk, t]) for t ∈ 𝒯) + @test all(is_fixed.(m[:cap_inst][snk, t]) for t ∈ 𝒯) + @test all(value.(m[:cap_inst][snk, t]) β‰ˆ capacity(snk, t) for t ∈ 𝒯) + + # Test that the fixed OPEX is set to 0 + # - EMB.constraints_opex_fixed(m, n::Sink, 𝒯ᴡⁿᡛ, modeltype::EnergyModel) + @test all(is_fixed.(m[:opex_fixed][snk, t_inv]) for t_inv ∈ 𝒯ᴡⁿᡛ) + @test all(value.(m[:opex_fixed][snk, t_inv]) β‰ˆ 0 for t_inv ∈ 𝒯ᴡⁿᡛ) + + # Test that the variable OPEX is correctly calculated + # - EMB.constraints_opex_fixed(m, n::AbstractPeriodDemandSink, 𝒯ᴡⁿᡛ, modeltype::EnergyModel) + @test all( + value.(m[:opex_var][snk, t_inv]) β‰ˆ + value.(m[:demand_sink_strat_surplus][snk, t_inv]) * surplus_penalty(snk, t_inv) + + value.(m[:demand_sink_strat_deficit][snk, t_inv]) * deficit_penalty(snk, t_inv) + + sum( + ( + value.(m[:demand_sink_surplus][snk, t_pd]) * surplus_penalty(snk, t_pd) + + value.(m[:demand_sink_deficit][snk, t_pd]) * deficit_penalty(snk, t_pd) + ) * scale_op_sp(t_inv, first(t_pd)) / duration(first(t_pd)) + for t_pd ∈ EMF.periods(snk, t_inv) + ) + for t_inv ∈ 𝒯ᴡⁿᡛ) + @test all( + value.(m[:opex_var][snk, t_inv]) β‰ˆ + 0 + 0 + + 1e4 * 0.1 * EMF.strategic_demand(snk, t_inv) + for t_inv ∈ 𝒯ᴡⁿᡛ) + + return objective_value(m) + end + + # Create and optimize the model + type = StratPeriodDemandSink + ops = vcat([2, 2, 2], ones(14), [4]) + 𝒯 = TwoLevel(2, 1, SimpleTimes(repeat(ops, 7)), op_per_strat=8760.) + m, case, modeltype = per_dem_snk_case(; 𝒯, type) + obj_1 = strat_per_sink_test(m, case) + + 𝒯 = TwoLevel(2, 1, OperationalScenarios(2, SimpleTimes(repeat(ops, 7))), op_per_strat=8760.) + m, case, modeltype = per_dem_snk_case(; 𝒯, type) + obj_2 = strat_per_sink_test(m, case; oscs=2) + + 𝒯 = TwoLevel(2, 1, RepresentativePeriods(7, 8760, SimpleTimes(ops)), op_per_strat=8760.) + m, case, modeltype = per_dem_snk_case(; 𝒯, type, repr=true) + obj_3 = strat_per_sink_test(m, case; repr=true) + + @test obj_1 β‰ˆ obj_2 rtol=1e-4 + @test obj_1 β‰ˆ obj_3 rtol=1e-4 end From ca922f1dddfb78a491219af181f51ba056bd1040 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 17 Jun 2026 16:58:04 +0200 Subject: [PATCH 2/6] Added checks for the StratPeriodDemandSink --- src/sink/checks.jl | 125 ++++++++++++++++++++++++++- test/sink/test_PeriodDemandSink.jl | 130 +++++++++++++++++++++++------ 2 files changed, 226 insertions(+), 29 deletions(-) diff --git a/src/sink/checks.jl b/src/sink/checks.jl index 3493275..2c9a95a 100644 --- a/src/sink/checks.jl +++ b/src/sink/checks.jl @@ -3,10 +3,6 @@ This method checks that a [`PeriodDemandSink`](@ref) node is valid. -It reuses the standard checks of a `Sink` node through calling the function -[`EMB.check_node_default`](@extref EnergyModelsBase.check_node_default), but adds -additional checks on the data. - ## Checks - The field `cap` is required to be non-negative. - The values of the dictionary `input` are required to be non-negative. @@ -81,6 +77,127 @@ function EMB.check_node( ) end end +""" + check_node(n::StratPeriodDemandSink, 𝒯, ::EnergyModel) + +This method checks that a [`StratPeriodDemandSink`](@ref) node is valid. + +## Checks +- The field `cap` is required to be non-negative. +- The values of the dictionary `input` are required to be non-negative. +- The dictionary `penalty` is required to have the keys `:deficit` and `:surplus`. +- The values `:deficit` and `:surplus` of the dictionary `penalty` are required to be + indexable by a `StrategicPeriod`. +- The sum of the values `:deficit` and `:surplus` in the dictionary `penalty` has to be + non-negative to avoid an infeasible model. +- The strategic demand must be positive and indexable by a strategic period. +- The individual periods must all satisfy the specified duration(s). +- The field `period_min` is required to be in the range [0, 1], indexable by a + `PeriodPartition`, and the sum within a strategic period must be smaller than 1 + (only a warning is thrown, as the model is still solvable). +- The field `period_max` is required to be in the range [0, 1] and indexable by a + `PeriodPartition`, and the sum within a strategic period must be larger than 1 + (only a warning is thrown, as the model is still solvable). +""" +function EMB.check_node( + n::StratPeriodDemandSink, + 𝒯, + modeltype::EnergyModel, + check_timeprofiles::Bool, +) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + π’―α΅–α΅ˆ = periods(n, 𝒯) + per_dur = period_duration(n) + bool = true + + @assert_or_log( + all(capacity(n, t) β‰₯ 0 for t ∈ 𝒯), + "The capacity must be non-negative." + ) + @assert_or_log( + all(inputs(n, p) β‰₯ 0 for p ∈ inputs(n)), + "The values for the Dictionary `input` must be non-negative." + ) + @assert_or_log( + :surplus ∈ keys(n.penalty) && :deficit ∈ keys(n.penalty), + "The entries `:surplus` and `:deficit` are required in the dictionary `penalty`." + ) + if :surplus ∈ keys(n.penalty) + message = "are not allowed for the key `:surplus` in the dictionary `penalty`." + bool *= EMB.check_strategic_profile(surplus_penalty(n), message) + else + bool = false + end + if :deficit ∈ keys(n.penalty) + message = "are not allowed for the key `:deficit` in the dictionary `penalty`." + bool *= EMB.check_strategic_profile(deficit_penalty(n), message) + else + bool = false + end + + if bool + @assert_or_log( + all(surplus_penalty(n, t_pd) + deficit_penalty(n, t_pd) β‰₯ 0 for t_pd ∈ π’―α΅–α΅ˆ), + "An inconsistent combination of `:surplus` and `:deficit` leads to an infeasible model." + ) + end + + message = "are not allowed for the field `:strat_demand`." + bool = EMB.check_strategic_profile(strategic_demand(n), message) + if bool + @assert_or_log( + all(strategic_demand(n, t_inv) β‰₯ 0 for t_inv ∈ 𝒯ᴡⁿᡛ), + "The strategic demand must be non-negative." + ) + end + + @assert_or_log( + all(sum(duration(t) for t ∈ t_pd) β‰₯ per_dur[t_pd] for t_pd ∈ π’―α΅–α΅ˆ), + "The duration of the last period on the `SimpleTimes` level is shorther than " * + "specified. This is caused by inconsistently specified `period_duration` and" * + "time structure." + ) + + message = "are not allowed for the field `:period_min`." + bool = EMB.check_partition_profile(period_demand_min(n), message) + if bool + @assert_or_log( + all(0 ≀ period_demand_min(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ), + "The minimum demand to be satisfied in a period must be in the range [0, 1]." + ) + bool = any( + sum(period_demand_min(n, t_pd) for t_pd ∈ periods(n, t_inv)) > 1 + for t_inv ∈ 𝒯ᴡⁿᡛ) + if bool + @warn( + "The sum of the minimum period demands is in at least one strategic period " * + "larger than 1. As a consequence, a deficit for `demand_sink_deficit` is " * + "guaranteed.", + maxlog=1 + ) + end + end + + message = "are not allowed for the field `:period_max`." + bool = EMB.check_partition_profile(period_demand_max(n), message) + if bool + @assert_or_log( + all(0 ≀ period_demand_max(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ), + "The maximum demand to be satisfied in a period must be in the range [0, 1]." + ) + bool = any( + sum(period_demand_max(n, t_pd) for t_pd ∈ periods(n, t_inv)) < 1 + for t_inv ∈ 𝒯ᴡⁿᡛ) + if bool + @warn( + "The sum of the maximum period demands is in at least one strategic period " * + "smaller than 1. As a consequence, a surplus for `demand_sink_surplus` is " * + "guaranteed.", + maxlog=1 + ) + end + end +end """ EMB.check_node(n::LoadShiftingNode, 𝒯, ::EnergyModel, check_timeprofiles::Bool) diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index d9ea24c..6d2a09d 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -42,15 +42,15 @@ function per_dem_snk_case(; ]) per_dem = RepresentativeProfile([fill(1500, 5)..., 0, 0]) snk_sur = RepresentativeProfile(vcat([-8], zeros(6))) - period_min = RepresentativeProfile(vcat(ones(5)*.10, [0, 0])) - period_max = RepresentativeProfile(vcat(ones(5)*.25, [0, 0])) + per_min = RepresentativeProfile(vcat(ones(5)*.10, [0, 0])) + per_max = RepresentativeProfile(vcat(ones(5)*.25, [0, 0])) else el_cost = OperationalProfile(vcat(day_1, repeat(day_rest, 3), fill(1e9, 18), zeros(36))) week_prod = OperationalProfile(vcat(repeat(weekday_prod, 5), zeros(36))) per_dem = PartitionProfile([fill(1500, 5)..., 0, 0]) snk_sur = PartitionProfile(vcat([-8], zeros(6))) - period_min = PartitionProfile(vcat(ones(5)*.10, [0, 0])) - period_max = PartitionProfile(vcat(ones(5)*.25, [0, 0])) + per_min = PartitionProfile(vcat(ones(5)*.10, [0, 0])) + per_max = PartitionProfile(vcat(ones(5)*.25, [0, 0])) end src = RefSource( @@ -84,8 +84,8 @@ function per_dem_snk_case(; week_prod, StrategicProfile(ones(length(strategic_periods(𝒯))) * strat_demand), 24, - period_min, - period_max, + per_min, + per_max, Dict( :surplus => FixedProfile(0), :deficit => FixedProfile(1e4), @@ -173,6 +173,87 @@ end @test_throws AssertionError check_per_dem_sink(; per_dem=FixedProfile(-10)) end + # Test that the fields of a `StratPeriodDemandSink` are correctly checked + # - EMB.check_node(n::StratPeriodDemandSink, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) + @testset "Check - StratPeriodDemandSink" begin + function check_per_dem_sink(; + cap = FixedProfile(10), + strat_demand = FixedProfile(1500*5/168), + per_dur = 24, + per_min = PartitionProfile([10, 10, 10, 10, 0, 0]./100), + per_max = PartitionProfile([25, 25, 25, 25, 0, 0]./100), + penalty = Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), + input = Dict(Power => 1), + 𝒯 = TwoLevel(1, 1, SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7))), + ) + snk = StratPeriodDemandSink( + "demand_product", + cap, + strat_demand, + per_dur, + per_min, + per_max, + penalty, + input, + ) + + return per_dem_snk_case(; snk, 𝒯) + end + + # Test that a wrong capacity is caught by the checks + @test_throws AssertionError check_per_dem_sink(; cap=FixedProfile(-25)) + + # Test that a wrong strategic demand is caught by the checks + @test_throws AssertionError check_per_dem_sink(; strat_demand=FixedProfile(-25)) + @test_throws AssertionError check_per_dem_sink(; strat_demand=OperationalProfile([1])) + + # Test that a wrong input is caught by the checks + @test_throws AssertionError check_per_dem_sink(; input = Dict(Power => -1)) + + # Test that a wrong penalty dictionary is caught + penalties = [ + Dict(:surplus => FixedProfile(0)), + Dict(:deficit => FixedProfile(0)), + Dict(:surplus => OperationalProfile([0]), :deficit => FixedProfile(1e4)), + Dict(:surplus => FixedProfile(0), :deficit => OperationalProfile([1e4])), + Dict(:surplus => RepresentativeProfile([0]), :deficit => FixedProfile(1e4)), + Dict(:surplus => FixedProfile(0), :deficit => RepresentativeProfile([1e4])), + Dict(:surplus => FixedProfile(-1e5), :deficit => FixedProfile(1e4)), + ] + for penalty ∈ penalties + @test_throws AssertionError check_per_dem_sink(; penalty) + end + + # Test that a wrong period length is caught by the checks, including in other time + # structures + @test_throws AssertionError check_per_dem_sink(; per_dur=25) + week = SimpleTimes(repeat(vcat([2, 2, 2], ones(14), [4]), 7)) + opscen = OperationalScenarios(2, [week, week], [0.5, 0.5]) + 𝒯 = TwoLevel(1, 1, opscen; op_per_strat=8760.) + @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) + rep = RepresentativePeriods(2, 8760., [.5, .5], [week, week]) + 𝒯 = TwoLevel(1, 1, rep; op_per_strat=8760.) + @test_throws AssertionError check_per_dem_sink(; per_dur=25, 𝒯) + + # Test that a wrong period limits are caught by the checks + @test_throws AssertionError check_per_dem_sink(; per_min=OperationalProfile([25])) + @test_throws AssertionError check_per_dem_sink(; per_min=FixedProfile(-1)) + @test_throws AssertionError check_per_dem_sink(; per_min=FixedProfile(1.1)) + @test_throws AssertionError check_per_dem_sink(; per_max=OperationalProfile([25])) + @test_throws AssertionError check_per_dem_sink(; per_max=FixedProfile(-1)) + @test_throws AssertionError check_per_dem_sink(; per_max=FixedProfile(1.1)) + + # Test that warnings are provided if the period limits enforce using the penalties + msg = "The sum of the minimum period demands is in at least one strategic period " * + "larger than 1. As a consequence, a deficit for `demand_sink_deficit` is " * + "guaranteed." + @test_logs (:warn, msg) check_per_dem_sink(; per_min=FixedProfile(0.5)) + msg = "The sum of the maximum period demands is in at least one strategic period " * + "smaller than 1. As a consequence, a surplus for `demand_sink_surplus` is " * + "guaranteed." + @test_logs (:warn, msg) check_per_dem_sink(; per_max=FixedProfile(0.1)) + end + # Set the global again to false EMB.TEST_ENV = false end @@ -191,28 +272,28 @@ end Dict(Power => 0.5), ) strat_demand = FixedProfile(1500*5/168) - period_min = PartitionProfile([10, 10, 10, 10, 0, 0]) - period_max = PartitionProfile([25, 25, 25, 25, 0, 0]) + per_min = PartitionProfile([10, 10, 10, 10, 0, 0]) + per_max = PartitionProfile([25, 25, 25, 25, 0, 0]) strat_snk = StratPeriodDemandSink( "demand_product", cap, strat_demand, per_dur, - period_min, - period_max, + per_min, + per_max, Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ) strat_demand = FixedProfile(1500*5/168) - period_min = PartitionProfile([10, 10, 10, 10, 0, 0]) - period_max = PartitionProfile([25, 25, 25, 25, 0, 0]) + per_min = PartitionProfile([10, 10, 10, 10, 0, 0]) + per_max = PartitionProfile([25, 25, 25, 25, 0, 0]) strat_snk = StratPeriodDemandSink( "demand_product", cap, strat_demand, per_dur, - period_min, - period_max, + per_min, + per_max, Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ) @@ -256,7 +337,6 @@ end Dict(Power => 0.5), ) snk_6 = PeriodDemandSink( - "demand_product", per_dur, [fill(1500, 5)..., 0, 0], @@ -299,8 +379,8 @@ end cap, strat_demand, per_dur, - period_min, - period_max, + per_min, + per_max, Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ExtensionData[] @@ -310,8 +390,8 @@ end cap, strat_demand, FixedProfile(per_dur), - period_min, - period_max, + per_min, + per_max, Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ) @@ -320,8 +400,8 @@ end cap, strat_demand, PartitionProfile(ones(7)*24), - period_min, - period_max, + per_min, + per_max, Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e4)), Dict(Power => 0.5), ) @@ -401,13 +481,13 @@ end @test all( EMF.strategic_demand(strat_snk, t_inv) == strat_demand[t_inv] for t_inv ∈ strategic_periods(𝒯)) - @test EMF.period_demand_min(strat_snk) == period_min + @test EMF.period_demand_min(strat_snk) == per_min @test all( - EMF.period_demand_min(strat_snk, t_dp) == period_min[t_dp] + EMF.period_demand_min(strat_snk, t_dp) == per_min[t_dp] for t_dp ∈ EMF.periods(strat_snk, 𝒯)) - @test EMF.period_demand_max(strat_snk) == period_max + @test EMF.period_demand_max(strat_snk) == per_max @test all( - EMF.period_demand_max(strat_snk, t_dp) == period_max[t_dp] + EMF.period_demand_max(strat_snk, t_dp) == per_max[t_dp] for t_dp ∈ EMF.periods(strat_snk, 𝒯)) end end From c4f9e11f0480e672c1444bc6ecf77df578c568bc Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Wed, 1 Jul 2026 09:43:02 +0200 Subject: [PATCH 3/6] Commented out FieldError in test for LTS support --- test/sink/test_PeriodDemandSink.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index 6d2a09d..e42d6d9 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -476,7 +476,7 @@ end # Test that all EMF extraction functions are working @test EMF.periods(strat_snk, 𝒯) == partition_duration(𝒯, per_dur) - @test_throws FieldError EMF.period_demand(strat_snk) + # @test_throws FieldError EMF.period_demand(strat_snk) @test EMF.strategic_demand(strat_snk) == strat_demand @test all( EMF.strategic_demand(strat_snk, t_inv) == strat_demand[t_inv] From 9e57863cbf219032dac2ac0ff0b57dfa08c144e2 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Fri, 3 Jul 2026 14:05:46 +0200 Subject: [PATCH 4/6] Added warning for guaranteed strategic deficit --- src/sink/checks.jl | 44 +++++++++++++++++++----------- src/sink/datastructures.jl | 2 +- test/sink/test_PeriodDemandSink.jl | 9 +++++- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/sink/checks.jl b/src/sink/checks.jl index 2c9a95a..eb15286 100644 --- a/src/sink/checks.jl +++ b/src/sink/checks.jl @@ -91,12 +91,14 @@ This method checks that a [`StratPeriodDemandSink`](@ref) node is valid. - The sum of the values `:deficit` and `:surplus` in the dictionary `penalty` has to be non-negative to avoid an infeasible model. - The strategic demand must be positive and indexable by a strategic period. +- The maximum capacity per operational period must be sufficient to satisfy the annual demand + A warning is printed if this is not the case. - The individual periods must all satisfy the specified duration(s). - The field `period_min` is required to be in the range [0, 1], indexable by a - `PeriodPartition`, and the sum within a strategic period must be smaller than 1 + `PeriodPartition`. Te sum within a strategic period should be smaller than or equal to 1 (only a warning is thrown, as the model is still solvable). - The field `period_max` is required to be in the range [0, 1] and indexable by a - `PeriodPartition`, and the sum within a strategic period must be larger than 1 + `PeriodPartition`. The sum within a strategic period should be larger than or equal to 1 (only a warning is thrown, as the model is still solvable). """ function EMB.check_node( @@ -110,10 +112,8 @@ function EMB.check_node( per_dur = period_duration(n) bool = true - @assert_or_log( - all(capacity(n, t) β‰₯ 0 for t ∈ 𝒯), - "The capacity must be non-negative." - ) + bool_cap = all(capacity(n, t) β‰₯ 0 for t ∈ 𝒯) + @assert_or_log(bool_cap, "The capacity must be non-negative.") @assert_or_log( all(inputs(n, p) β‰₯ 0 for p ∈ inputs(n)), "The values for the Dictionary `input` must be non-negative." @@ -145,10 +145,20 @@ function EMB.check_node( message = "are not allowed for the field `:strat_demand`." bool = EMB.check_strategic_profile(strategic_demand(n), message) if bool - @assert_or_log( - all(strategic_demand(n, t_inv) β‰₯ 0 for t_inv ∈ 𝒯ᴡⁿᡛ), - "The strategic demand must be non-negative." - ) + bool_strat = all(strategic_demand(n, t_inv) β‰₯ 0 for t_inv ∈ 𝒯ᴡⁿᡛ) + @assert_or_log(bool_strat, "The strategic demand must be non-negative." ) + bool_strat *= any( + sum(capacity(n, t) * scale_op_sp(t_inv, t) for t ∈ t_inv) ≀ + strategic_demand(n, t_inv) + for t_inv ∈ 𝒯ᴡⁿᡛ) * all(capacity(n, t) β‰₯ 0 for t ∈ 𝒯) + if bool_strat + @warn( + "The scaled summation of the capacity in each operational period is " * + "smaller than the strategic demand in at least one strategic period. As a " * + "consequence, a deficit for `demand_sink_strat_deficit` is guaranteed.", + maxlog=1 + ) + end end @assert_or_log( @@ -161,14 +171,15 @@ function EMB.check_node( message = "are not allowed for the field `:period_min`." bool = EMB.check_partition_profile(period_demand_min(n), message) if bool + bool_min = all(0 ≀ period_demand_min(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ) @assert_or_log( - all(0 ≀ period_demand_min(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ), + bool_min, "The minimum demand to be satisfied in a period must be in the range [0, 1]." ) - bool = any( + bool_min *= any( sum(period_demand_min(n, t_pd) for t_pd ∈ periods(n, t_inv)) > 1 for t_inv ∈ 𝒯ᴡⁿᡛ) - if bool + if bool_min @warn( "The sum of the minimum period demands is in at least one strategic period " * "larger than 1. As a consequence, a deficit for `demand_sink_deficit` is " * @@ -181,14 +192,15 @@ function EMB.check_node( message = "are not allowed for the field `:period_max`." bool = EMB.check_partition_profile(period_demand_max(n), message) if bool + bool_max = all(0 ≀ period_demand_max(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ) @assert_or_log( - all(0 ≀ period_demand_max(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ), + bool_max, "The maximum demand to be satisfied in a period must be in the range [0, 1]." ) - bool = any( + bool_max *= any( sum(period_demand_max(n, t_pd) for t_pd ∈ periods(n, t_inv)) < 1 for t_inv ∈ 𝒯ᴡⁿᡛ) - if bool + if bool_max @warn( "The sum of the maximum period demands is in at least one strategic period " * "smaller than 1. As a consequence, a surplus for `demand_sink_surplus` is " * diff --git a/src/sink/datastructures.jl b/src/sink/datastructures.jl index 7d037ff..546749e 100644 --- a/src/sink/datastructures.jl +++ b/src/sink/datastructures.jl @@ -151,7 +151,7 @@ of the total demand that can be satisified within the demand period. most be satisifed in each demand period. - **`penalty::Dict{Symbol,<:TimeProfile}`** are penalties for surplus or deficits. The dictionary requires the fields `:surplus` and `:deficit`. The same penalty is utilized for - the strategic surplus/deficit and period surplus/deficit, al + the strategic surplus/deficit and period surplus/deficit. - **`input::Dict{<:Resource,<:Real}`** are the input [`Resource`](@extref EnergyModelsBase.Resource)s with conversion value `Real`. - **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index e42d6d9..1a50343 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -177,7 +177,7 @@ end # - EMB.check_node(n::StratPeriodDemandSink, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) @testset "Check - StratPeriodDemandSink" begin function check_per_dem_sink(; - cap = FixedProfile(10), + cap = FixedProfile(1500), strat_demand = FixedProfile(1500*5/168), per_dur = 24, per_min = PartitionProfile([10, 10, 10, 10, 0, 0]./100), @@ -207,6 +207,13 @@ end @test_throws AssertionError check_per_dem_sink(; strat_demand=FixedProfile(-25)) @test_throws AssertionError check_per_dem_sink(; strat_demand=OperationalProfile([1])) + # Test that a warning is thrown if the operational capacity is to small to satisfy + # the strategic period demand + msg = "The scaled summation of the capacity in each operational period is smaller " * + "than the strategic demand in at least one strategic period. As a consequence, " * + "a deficit for `demand_sink_strat_deficit` is guaranteed." + @test_logs (:warn, msg) check_per_dem_sink(; cap=FixedProfile(0.1)) + # Test that a wrong input is caught by the checks @test_throws AssertionError check_per_dem_sink(; input = Dict(Power => -1)) From 446f9d6173d6d7ce3bd19c345c289d1763e5bd10 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Fri, 3 Jul 2026 15:41:41 +0200 Subject: [PATCH 5/6] Updated the documentation and NEWS.md --- NEWS.md | 7 +- Project.toml | 2 +- README.md | 2 +- docs/make.jl | 1 + docs/src/index.md | 3 +- docs/src/nodes/sink/perioddemand.md | 32 ++-- docs/src/nodes/sink/stratperioddemand.md | 209 +++++++++++++++++++++++ 7 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 docs/src/nodes/sink/stratperioddemand.md diff --git a/NEWS.md b/NEWS.md index 56f5187..76dc67b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,6 @@ # Release notes -## Unversioned +## Version 0.4.0 (2026-07-XX) ### Breaking changes @@ -14,6 +14,11 @@ * Rewrote `CapacityCostLink` with `PeriodPartition` (introduced in *[`TimeStruct` 0.9.12](https://github.com/sintefore/TimeStruct.jl/releases/tag/v0.9.12)*) to increase flexibility of the link with respect to the time structure. * Rewriting requires adjustment of the parameters due to changed meaning of some of the values. +### New node `StratPeriodDemandSink` + +* Introduced new node type `StratPeriodDemandSink` as subtype of `AbstractPeriodDemandSink`. +* Node to be used for strategic demands and lower and upper bounds for satisfying the demand within a demand period. + ## Version 0.3.0 (2026-04-16) ### Breaking changes diff --git a/Project.toml b/Project.toml index a38acc4..f09edc3 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "EnergyModelsFlex" uuid = "a81b9388-333d-4b63-81f2-910b060b544c" authors = ["Sigrid Aunsmo, Sigmund Eggen Holm, Jon Vegard VenΓ₯s, and Per Γ…slid"] -version = "0.3.0" +version = "0.4.0" [deps] EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" diff --git a/README.md b/README.md index 2f439a6..d68370a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ > As a consequence, it is advised to read the documentation for each node to identify their usefulness. > Is is planned to remove some nodes and rewrite the behaviour of other nodes to improve their flexibility. > -> Among others, using `PeriodDemandSink` and `CapacityCostLink` in combination with `EnergyModelsGUI` results in errors when trying to access fields that have as values `PartitionProfile`. +> Among others, using `PeriodDemandSink`, `StratPeriodDemandSink`, and `CapacityCostLink` in combination with `EnergyModelsGUI` results in errors when trying to access fields that have as values `PartitionProfile`. > The same holds for variables that are defined over `PeriodPartition`s where you cannot see the results. ## Usage diff --git a/docs/make.jl b/docs/make.jl index 00b8a72..cbd70f9 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -59,6 +59,7 @@ makedocs( ], "Sink nodes"=>Any[ "PeriodDemandSink"=>"nodes/sink/perioddemand.md", + "StratPeriodDemandSink"=>"nodes/sink/stratperioddemand.md", "LoadShiftingNode"=>"nodes/sink/loadshiftingnode.md", "MultipleInputSink"=>"nodes/sink/multipleinputsink.md", "AbstractMultipleInputSinkStrat"=>"nodes/sink/multipleinputsinkstrat.md", diff --git a/docs/src/index.md b/docs/src/index.md index 1ac2444..7aabe52 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -30,7 +30,8 @@ This package provides several node types that extend the EnergyModelsX interface ### Sink Nodes -- [`PeriodDemandSink`](@ref nodes-perioddemandsink): Allows demand to be met flexibly within a defined time period (e.g. daily energy use). +- [`PeriodDemandSink`](@ref nodes-perioddemandsink): Allows demand to be met flexibly within a defined demand period (*e.g.*, daily energy use). +- [`StratPeriodDemandSink`](@ref nodes-stratperioddemandsink): a variation of `PeriodDemandSink` where the demand must be satisfied within a strategic period with bound on the utilization in demand periods. - [`LoadShiftingNode`](@ref nodes-loadshiftingnode): Supports discrete batch shifting across time within allowed work shifts. - [`MultipleInputSink`](@ref nodes-mul_in_sink): Enables flexible use of multiple input resources to meet demand. - [`BinaryMultipleInputSinkStrat`](@ref nodes-mul_in_sink_strat): Input choice from multiple fuels using binary (exclusive) decisions per period. diff --git a/docs/src/nodes/sink/perioddemand.md b/docs/src/nodes/sink/perioddemand.md index 55db89e..7548bdd 100644 --- a/docs/src/nodes/sink/perioddemand.md +++ b/docs/src/nodes/sink/perioddemand.md @@ -1,15 +1,15 @@ # [PeriodDemandSink node](@id nodes-perioddemandsink) [`PeriodDemandSink`](@ref) nodes represent flexible demand sinks where demand must be fulfilled within defined periods (*e.g.* daily or weekly), rather than in each individual operational time step. -**A *period* is thus a consecutive range of operational periods, that together will model, *e.g.*, a day or a week etc.** +A *demand period* is a consecutive range of operational periods, that together will model, *e.g.*, a day, a week or comparable. This node can, *e.g.*, be combined with [`MinUpDownTimeNode`](@ref), to allow production to be moved to the time of the day when it is cheapest because of, *e.g.*, energy or production costs. !!! tip "Example" - This node is included in an [example](@ref examples-flexible_demand) to demonstrate flexible demand. + This node is included in an *[example](@ref examples-flexible_demand)* to demonstrate flexible demand. !!! warning "TimeStructure for node" - This node is designed for **uniform or repetitive duration of operational periods**. + This node requires considerations of the operational time structure and the chosen demand period duration. Irregular durations may cause misalignment of shifted loads, especially if the field `period_duration` does not align with the chosen [`SimpleTimes`](@extref TimeStruct.SimpleTimes) structure representing the operational periods. !!! warning "`PeriodDemandSink` and `EnergyModelsGUI`" @@ -41,8 +41,7 @@ The standard fields are given as: In addition, it is crucial that the sum of both values in each demand period is larger than 0 to avoid an unconstrained model. !!! warning "Chosen values" - The implementation is relative to the chosen `period_duration` (see below). - If the period duration is ``24``, then the cost is for the unsatisfied demand within the ``24`` demand period, multiplied with the probability and the repetitons within a strategic period. + The implementation for the demand period is relative to the chosen duration of a strategic period while the demand period deficit and surplus is scaled to a strategic period in the calculation. - **`input::Dict{<:Resource,<:Real}`**:\ The field `input` includes [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries.\ @@ -65,7 +64,7 @@ The standard fields are given as: [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink)s require additional fields to specify both the periods and their respective demands: - **`period_duration::TimeProfile`**:\ - Defines the total duration of a single demand period.\ + Defines the total duration of the demand periods. For instance, if the duration of 1 of the operational time structure is 1 hour and `period_duration = FixedProfile(24)`, then each demand period spans one day. The demand of this node (for a given day, see below) must then be filled on a daily basis, without any restrictions on *when* during the day the demand must be filled given the available capacity.\ Due to a constructor, it can either be specified as number (the same duration in all demand periods), as a vector (varying duration of each demand period), or as a time profile (*e.g.*, varying period durations due to varying operational time structures). @@ -73,15 +72,15 @@ The standard fields are given as: - **`period_demand::TimeProfile`**:\ The total demand to be met during each demand period. - The length of this time profile should match the number of periods (*e.g.*, days) in the time structure. - If the time structure represents one year with hourly resolution and the demand periods correspond to a day, this time profile must then have 365 elements. + The length of this time profile should match the number of demand periods (*e.g.*, days) in the time structure. + If the time structure represents one year with hourly resolution and the demand periods correspond to a day, this time profile must then have 365 elements.\ + It cannot be specified as `OperationalProfile`. It is best to utilize the [`PartitionProfile`](@extref TimeStruct.PartitionProfile) type if the demand is varying. - If it is constant, you can also utilize [`StrategicProfile`][@extref TimeStruct.StrategicProfile], [`RepresentativeProfile`][@extref TimeStruct.RepresentativeProfile], or [`ScenarioProfile`][@extref TimeStruct.ScenarioProfile], depending on your chosen time structure. - It cannot be specified as `OperationalProfile`. + If it is constant, you can also utilize [`StrategicProfile`](@extref TimeStruct.StrategicProfile), [`RepresentativeProfile`](@extref TimeStruct.RepresentativeProfile), or [`ScenarioProfile`](@extref TimeStruct.ScenarioProfile), depending on your chosen time structure. !!! warning "Time consistency" - Ensure that the `period_demand` time profile length aligns with the operational time horizon duration divided by `period_duration` + Ensure that the `period_demand` time profile length aligns with the periods specified by `period_duration`. Mismatches can lead to indexing errors or inconsistent demand enforcement. These fields are at the 3ʳᡈ and 4α΅—Κ° position below the field `cap` as shown in [`PeriodDemandSink`](@ref). @@ -117,13 +116,12 @@ The variables include: #### [Additional variables](@id nodes-perioddemandsink-math-add) -[`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink) nodes declare in addition several variables through dispatching on the method [`EnergyModelsBase.variables_element()`](@ref) for including constraints for deficits and surplus for individual resources as well as what the fraction satisfied by each resource. -These variables are for a [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink) node ``n`` in demand periods ``t_pd``: +[`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink) nodes declare in addition several variables through dispatching on the method [`EnergyModelsBase.variables_element()`](@ref) for including constraints for deficits and surplus for individual demand periods. - ``\texttt{demand\_sink\_surplus}[n, t_pd]``:\ - Surplus of energy delivered beyond the required `period_demand` of demand period `t_pd` . + Surplus of energy delivered beyond the required `period_demand` in demand period `t_pd` . - ``\texttt{demand\_sink\_deficit}[n, t_pd]``:\ - Deficit of energy delivered relative to the `period_demand` of demand period `t_pd` . + Deficit of energy delivered relative to the `period_demand` in demand period `t_pd` . ### [Constraints](@id nodes-perioddemandsink-math-con) @@ -193,8 +191,8 @@ As a consequence, `constraints_opex_var` requires as well a new method as we onl ```math \begin{aligned} -\texttt{opex\_var}[n, t_{inv}] = \sum_{t_{pd} ∈ periods(t_{inv})}(& \texttt{demand\_sink\_surplus}[n, t_{pd}] \times \texttt{surplus\_penalty}(n, t_{pd}) + \\ -& \texttt{demand\_sink\_deficit}[n, t_{pd}] \times \texttt{deficit\_penalty}(n, t_{pd})) \times \\ +\texttt{opex\_var}[n, t_{inv}] = & \sum_{t_{pd} ∈ periods(t_{inv})}(\texttt{demand\_sink\_surplus}[n, t_{pd}] \times \texttt{surplus\_penalty}(n, t_{pd}) + {}\\ +& \phantom{\sum_{t_{pd} ∈ periods(t_{inv})}(} \texttt{demand\_sink\_deficit}[n, t_{pd}] \times \texttt{deficit\_penalty}(n, t_{pd})) \times {} \\ & scale\_op\_sp(t_{inv}, first(t_{pd})) / duration(first(t_{pd})) \end{aligned} ``` diff --git a/docs/src/nodes/sink/stratperioddemand.md b/docs/src/nodes/sink/stratperioddemand.md new file mode 100644 index 0000000..2d7c9a7 --- /dev/null +++ b/docs/src/nodes/sink/stratperioddemand.md @@ -0,0 +1,209 @@ +# [StratPeriodDemandSink node](@id nodes-stratperioddemandsink) + +[`StratPeriodDemandSink`](@ref) nodes represent flexible demand sinks where the demand must be fulfilled on an annual level while individual demand periods can be specified to incorporate lower and upper bounds on the demand satisfaction per demand period. +A *demand period* is a consecutive range of operational periods, that together will model, *e.g.*, a day, a week or comparable. + +!!! warning "TimeStructure for node" + This node requires considerations of the operational time structure and the chosen demand period duration. + Irregular durations may cause misalignment of shifted loads, especially if the field `period_duration` does not align with the chosen [`SimpleTimes`](@extref TimeStruct.SimpleTimes) structure representing the operational periods. + +!!! warning "`StratPeriodDemandSink` and `EnergyModelsGUI`" + Some of the fields of this node cannot be represented in `EnergyModelsGUI`. + The reason for that limitation is that `EnergyModelsGUI` does not yet support partitions of `TimePeriod`s. + `EnergyModelsGUI` can still be utilized for all other fields. + +## [Introduced type and its fields](@id nodes-stratperioddemandsink-fields) + +The [`StratPeriodDemandSink`](@ref) node is a subtype of [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink) which reutilizes the variables and some utility functions. +It provides however new capacity and variable operating expenses constraints. + +### [Standard fields](@id nodes-stratperioddemandsink-fields-stand) + +The standard fields are given as: + +- **`id`**:\ + The field `id` is only used for providing a name to the node. +- **`cap::TimeProfile`**:\ + The maximum amount of demand that can be met in each operational period.\ + A warning is printed if the sum of the scaled demand in a strategic period is lower than the strategic demand. +- **`penalty::Dict{Symbol,<:TimeProfile}`**:\ + The penalty dictionary is used for providing penalties for soft constraints to allow for both over and under delivering the demand.\ + It must include the fields `:surplus` and `:deficit`. + In addition, it is crucial that the sum of both values in each demand period is larger than 0 to avoid an unconstrained model. + + !!! warning "Chosen values" + The same value is chosen for violations of the lower and upper bounds for the individual demand periods and the strategic demand. + + The implementation for the demand period is relative to the chosen duration of a strategic period while the demand period deficit and surplus is scaled to a strategic period in the calculation. + +- **`input::Dict{<:Resource,<:Real}`**:\ + The field `input` includes [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries.\ + All values have to be non-negative. +- **`data::Vector{<:ExtensionData}`**:\ + An entry for providing additional data to the model. + In the current version, it is used for both providing `EmissionsData` and additional investment data when [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/) is used. + !!! note "Included constructor" + The field `data` is not required as we include a constructor when the value is excluded. + !!! danger "Using `CaptureData`" + As a `Sink` node does not have any output, it is not possible to utilize [`CaptureData`](@extref EnergyModelsBase.CaptureData). + If you still plan to specify it, you will receive an error in the model building. + +### [Additional fields](@id nodes-stratperioddemandsink-fields-new) + +[`StratPeriodDemandSink`](@ref EnergyModelsFlex.StratPeriodDemandSink)s require additional fields to specify both the periods and their respective demands: + +- **`strat_demand::TimeProfile`**:\ + The total demand within a strategic period relative to a duration of 1 of a strategic period. + If a duration of 1 corresponds to a year, this value will specify the annual demand that must be satisfied.\ + It must be indexable by a strategic period, that is it can be specified as `FixedProfile`, `StrategicProfile` or `StrategicStochasticProfile`. + +- **`period_duration::TimeProfile`**:\ + Defines the total duration of the demand periods. + For instance, if the duration of 1 of the operational time structure is 1 hour and `period_duration = FixedProfile(24)`, then each demand period spans one day. + Due to a constructor, it can either be specified as number (the same duration in all demand periods), as a vector (varying duration of each demand period), or as a time profile (*e.g.*, varying period durations due to varying operational time structures).\ + It cannot be specified as `OperationalProfile`. + +- **`period_min::TimeProfile`** and **`period_max::TimeProfile`**:\ + The fraction of annual demand that must be at least or can be at most satisfied within a demand period. + The length of this time profile should match the number of demand periods (*e.g.*, days) in the time structure.\ + They cannot be specified as `OperationalProfile`. + A warning is printed if either the sum of `period_min` is larger than 1 (guaranteeing a surplus penalty introduction) or if the sum of `period_max` is smaller than 1 (guaranteeing a deficit penalty introduction) + +!!! tip "Profiles for `period_min` and `period_max`" + It is best to utilize the [`PartitionProfile`](@extref TimeStruct.PartitionProfile) type if the fractions are varying in the individual demand periods. + If they are constant, you can also utilize [`StrategicProfile`](@extref TimeStruct.StrategicProfile), [`RepresentativeProfile`](@extref TimeStruct.RepresentativeProfile), or [`ScenarioProfile`](@extref TimeStruct.ScenarioProfile), depending on your chosen time structure. + +!!! warning "Time consistency" + Ensure that the `period_min` and `period_max` time profiles length aligns with the periods specified by `period_duration`. + Mismatches can lead to indexing errors or inconsistent demand enforcement. + +These fields are at the 3ʳᡈ and 4α΅—Κ° position below the field `cap` as shown in [`StratPeriodDemandSink`](@ref). + +## [Mathematical description](@id nodes-stratperioddemandsink-math) + +In the following mathematical equations, we use the name for variables and functions used in the model. +Variables are in general represented as + +``\texttt{var\_example}[index_1, index_2]`` + +with square brackets, while functions are represented as + +``func\_example(index_1, index_2)`` + +with parantheses. + +### [Variables](@id nodes-stratperioddemandsink-math-var) + +#### [Standard variables](@id nodes-stratperioddemandsink-math-var-stand) + +The [`StratPeriodDemandSink`](@ref) nodes utilize all standard variables from a `Sink` node, as described on the page *[Optimization variables](@extref EnergyModelsBase man-opt_var)*. +The variables include: + +- [``\texttt{opex\_var}``](@extref EnergyModelsBase man-opt_var-opex) +- [``\texttt{opex\_fixed}``](@extref EnergyModelsBase man-opt_var-opex) +- [``\texttt{cap\_use}``](@extref EnergyModelsBase man-opt_var-cap) +- [``\texttt{cap\_inst}``](@extref EnergyModelsBase man-opt_var-cap) +- [``\texttt{flow\_out}``](@extref EnergyModelsBase man-opt_var-flow) +- [``\texttt{sink\_surplus}``](@extref EnergyModelsBase man-opt_var-sink) +- [``\texttt{sink\_deficit}``](@extref EnergyModelsBase man-opt_var-sink) +- [``\texttt{emissions\_node}``](@extref EnergyModelsBase man-opt_var-emissions) if `EmissionsData` is added to the field `data` + +#### [Additional variables](@id nodes-stratperioddemandsink-math-add) + +[`StratPeriodDemandSink`](@ref) nodes declare in addition several variables through dispatching on the method [`EnergyModelsBase.variables_element()`](@ref) for including constraints for deficits and surplus for individual resources as well as what the fraction satisfied by each resource on both the level of demand periods (as introduced by the method for [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink)) and strategic periods (introduced through a dedicated methods for [`StratPeriodDemandSink`](@ref)): + +- ``\texttt{demand\_sink\_surplus}[n, t_pd]``:\ + Surplus of energy delivered beyond the required `period_max` fraction in demand period `t_pd` . +- ``\texttt{demand\_sink\_deficit}[n, t_pd]``:\ + Deficit of energy delivered relative to the required `period_min` fraction in demand period `t_pd` . +- ``\texttt{demand\_sink\_strat\_surplus}[n, t_inv]``:\ + Surplus of energy delivered beyond the required `strat_demand` in strategic period `t_inv` . +- ``\texttt{demand\_sink\_strat\_deficit}[n, t_inv]``:\ + Deficit of energy delivered relative to the required `strat_demand` in strategic period `t_inv` . + +### [Constraints](@id nodes-stratperioddemandsink-math-con) + +The following sections omit the direct inclusion of the vector of [`StratPeriodDemandSink`](@ref) nodes. +Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ N`` for all [`StratPeriodDemandSink`](@ref) types if not stated differently. +In addition, all constraints are valid ``\forall t \in T`` (that is in all operational periods) or ``\forall t_{inv} \in T^{Inv}`` (that is in all investment periods). + +#### [Standard constraints](@id nodes-stratperioddemandsink-math-con-stand) + +[`StratPeriodDemandSink`](@ref) utilize in general the standard constraints that are implemented for a [`Sink`](@extref EnergyModelsBase nodes-sink) node as described in the *[documentaiton of `EnergyModelsBase`](@extref EnergyModelsBase nodes-sink-math-con)*. +These standard constraints are: + +- `constraints_capacity_installed`: + + ```math + \texttt{cap\_inst}[n, t] = capacity(n, t) + ``` + + !!! tip "Using investments" + The function `constraints_capacity_installed` is also used in [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/) to incorporate the potential for investment. + Nodes with investments are then no longer constrained by the parameter capacity. + +- `constraints_flow_in`: + + ```math + \texttt{flow\_in}[n, t, p] = + inputs(n, p) \times \texttt{cap\_use}[n, t] + \qquad \forall p \in inputs(n) + ``` + + !!! tip "Multiple inputs" + The constrained above allows for the utilization of multiple inputs with varying ratios. + it is however necessary to deliver the fixed ratio of all inputs. + +- `constraints_opex_fixed`:\ + The current implementation fixes the fixed operating expenses of a sink to 0. + + ```math + \texttt{opex\_fixed}[n, t_{inv}] = 0 + ``` + +- `constraints_data`:\ + This function is only called for specified additional data, see above. + +The function `constraints_capacity` is extended with a new method to account for the calculation of the period demand deficit and surplus. + +The overall balance is modified as + +```math +\texttt{cap\_use}[n, t] + \texttt{sink\_deficit}[n, t] = \texttt{cap\_inst}[n, t] +``` + +while operational period surplus ``\texttt{sink\_surplus}[n, t]`` is fixed to 0. + +The deficit and surplus in a demand period can then be calculated as + +```math +\begin{aligned} +\sum_{t \in t_{pd}} \texttt{​cap\_use}[n, t] \times duration(t) + {} & \texttt{demand\_sink\_deficit}[n, t_{pd}] \geq \\ +& \frac{period\_demand\_min(n, t_{pd}) \times strategic\_demand(n, t_{inv})}{multiple(first(t_{pd})) / duration\_strat(t_{inv})} \\ +\sum_{t \in t_{pd}} \texttt{​cap\_use}[n, t] \times duration(t) \geq {} & \texttt{demand\_sink\_surplus}[n, t_{pd}] + {} \\ +& \frac{period\_demand\_max(n, t_{pd}) \times strategic\_demand(n, t_{inv})}{multiple(first(t_{pd})) / duration\_strat(t_{inv})} +\end{aligned} +``` + +where ``t_{pd}`` is the demand period consisting of a set of operational periods and ``t_{inv}`` is the strategic period. + +!!! note "`multiple` and `duration_strat`" + The division by ``multiple(first(t_{pd}))/duration\_strat(t_{inv})`` is introduced for scaling the strategic demand to duration 1 of an operational period. + The function [`scale_op_sp(t_inv, t)`](@extref EnergyModelsBase.scale_op_sp) cannot be used here as it includes as well the probability. + +As a consequence, `constraints_opex_var` requires as well a new method as we consider the deficit within a strategic period and penalize violation of the period demand bounds: + +```math +\begin{aligned} +\texttt{opex\_var}[n, t_{inv}] = {} & +\texttt{demand\_sink\_strat\_surplus}[n, t_{inv}] \times \texttt{surplus\_penalty}(n, t_{inv}) + {}\\ +& \texttt{demand\_sink\_strat\_deficit}[n, t_{inv}] \times \texttt{deficit\_penalty}(n, t_{inv}) + {}\\ +& \sum_{t_{pd} ∈ periods(t_{inv})}(\texttt{demand\_sink\_surplus}[n, t_{pd}] \times \texttt{surplus\_penalty}(n, t_{pd}) + {}\\ +&\phantom{\sum_{t_{pd} ∈ periods(t_{inv})}(} \texttt{demand\_sink\_deficit}[n, t_{pd}] \times \texttt{deficit\_penalty}(n, t_{pd})) \times {}\\ +& scale\_op\_sp(t_{inv}, first(t_{pd})) / duration(first(t_{pd})) +\end{aligned} +``` + +!!! note "`scale_op_sp` and `duration`" + The function [`scale_op_sp(t_inv, t)`](@extref EnergyModelsBase.scale_op_sp) calculates the scaling factor between operational and investment periods including the `duration` of each operational period. + It hence must be divided by the `duration` of the first operational period to avoid including the duration of the operational period. From 22006c0249356d087a897c5e55903470c0d8387a Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 6 Jul 2026 08:42:46 +0200 Subject: [PATCH 6/6] Included comments from PR review --- docs/src/nodes/sink/stratperioddemand.md | 4 ++-- src/sink/checks.jl | 17 +++++++++++++++-- test/sink/test_PeriodDemandSink.jl | 9 ++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/src/nodes/sink/stratperioddemand.md b/docs/src/nodes/sink/stratperioddemand.md index 2d7c9a7..33ffae9 100644 --- a/docs/src/nodes/sink/stratperioddemand.md +++ b/docs/src/nodes/sink/stratperioddemand.md @@ -110,7 +110,7 @@ The variables include: #### [Additional variables](@id nodes-stratperioddemandsink-math-add) -[`StratPeriodDemandSink`](@ref) nodes declare in addition several variables through dispatching on the method [`EnergyModelsBase.variables_element()`](@ref) for including constraints for deficits and surplus for individual resources as well as what the fraction satisfied by each resource on both the level of demand periods (as introduced by the method for [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink)) and strategic periods (introduced through a dedicated methods for [`StratPeriodDemandSink`](@ref)): +[`StratPeriodDemandSink`](@ref) nodes declare in addition several variables through dispatching on the method [`EnergyModelsBase.variables_element()`](@ref) for including constraints for deficits and surplus for individual demand periods (as introduced by the method for [`AbstractPeriodDemandSink`](@ref EnergyModelsFlex.AbstractPeriodDemandSink)) and strategic periods (introduced through a dedicated method for [`StratPeriodDemandSink`](@ref)): - ``\texttt{demand\_sink\_surplus}[n, t_pd]``:\ Surplus of energy delivered beyond the required `period_max` fraction in demand period `t_pd` . @@ -180,7 +180,7 @@ The deficit and surplus in a demand period can then be calculated as \begin{aligned} \sum_{t \in t_{pd}} \texttt{​cap\_use}[n, t] \times duration(t) + {} & \texttt{demand\_sink\_deficit}[n, t_{pd}] \geq \\ & \frac{period\_demand\_min(n, t_{pd}) \times strategic\_demand(n, t_{inv})}{multiple(first(t_{pd})) / duration\_strat(t_{inv})} \\ -\sum_{t \in t_{pd}} \texttt{​cap\_use}[n, t] \times duration(t) \geq {} & \texttt{demand\_sink\_surplus}[n, t_{pd}] + {} \\ +\sum_{t \in t_{pd}} \texttt{​cap\_use}[n, t] \times duration(t) \leq {} & \texttt{demand\_sink\_surplus}[n, t_{pd}] + {} \\ & \frac{period\_demand\_max(n, t_{pd}) \times strategic\_demand(n, t_{inv})}{multiple(first(t_{pd})) / duration\_strat(t_{inv})} \end{aligned} ``` diff --git a/src/sink/checks.jl b/src/sink/checks.jl index eb15286..965ba71 100644 --- a/src/sink/checks.jl +++ b/src/sink/checks.jl @@ -95,11 +95,13 @@ This method checks that a [`StratPeriodDemandSink`](@ref) node is valid. A warning is printed if this is not the case. - The individual periods must all satisfy the specified duration(s). - The field `period_min` is required to be in the range [0, 1], indexable by a - `PeriodPartition`. Te sum within a strategic period should be smaller than or equal to 1 + `PeriodPartition`. The sum within a strategic period should be smaller than or equal to 1 (only a warning is thrown, as the model is still solvable). - The field `period_max` is required to be in the range [0, 1] and indexable by a `PeriodPartition`. The sum within a strategic period should be larger than or equal to 1 (only a warning is thrown, as the model is still solvable). +- A warning is thrown if the field `period_min` is larger than the field `period_max` in any + demand period. """ function EMB.check_node( n::StratPeriodDemandSink, @@ -190,7 +192,7 @@ function EMB.check_node( end message = "are not allowed for the field `:period_max`." - bool = EMB.check_partition_profile(period_demand_max(n), message) + bool *= EMB.check_partition_profile(period_demand_max(n), message) if bool bool_max = all(0 ≀ period_demand_max(n, t_pd) ≀ 1 for t_pd ∈ π’―α΅–α΅ˆ) @assert_or_log( @@ -209,6 +211,17 @@ function EMB.check_node( ) end end + + if bool + if any(period_demand_min(n, t_pd) > period_demand_max(n, t_pd) for t_pd ∈ π’―α΅–α΅ˆ) + @warn( + "The minimum demand through the field `period_min` is larger than the " * + "maximum demand through the field `period_max` in at least one demand " * + "period resulting in a guranteed penalty", + maxlog=1 + ) + end + end end """ diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index 1a50343..133df3e 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -254,11 +254,18 @@ end msg = "The sum of the minimum period demands is in at least one strategic period " * "larger than 1. As a consequence, a deficit for `demand_sink_deficit` is " * "guaranteed." - @test_logs (:warn, msg) check_per_dem_sink(; per_min=FixedProfile(0.5)) + per_min = PartitionProfile([30, 30, 30, 30, 0, 0]./100) + per_max = PartitionProfile([40, 40, 40, 40, 0, 0]./100) + @test_logs (:warn, msg) check_per_dem_sink(; per_min, per_max) msg = "The sum of the maximum period demands is in at least one strategic period " * "smaller than 1. As a consequence, a surplus for `demand_sink_surplus` is " * "guaranteed." @test_logs (:warn, msg) check_per_dem_sink(; per_max=FixedProfile(0.1)) + msg = "The minimum demand through the field `period_min` is larger than the " * + "maximum demand through the field `period_max` in at least one demand " * + "period resulting in a guranteed penalty" + per_min = PartitionProfile([10, 10, 10, 10, 10, 0]./100) + @test_logs (:warn, msg) check_per_dem_sink(; per_min) end # Set the global again to false