From 0c2b03d8e568808302a7afaf8bf89b9e000178a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Mon, 22 Dec 2025 12:03:08 +0100 Subject: [PATCH 1/9] Added the new link CapacityCostLink --- Project.toml | 2 +- docs/make.jl | 10 + docs/src/library/internals/methods-EMB.md | 6 + docs/src/library/internals/methods-EMF.md | 5 + docs/src/library/public.md | 8 + docs/src/links/capacitycostlink.md | 123 +++++++++++ examples/capacity_cost_link.jl | 132 ++++++++++++ src/EnergyModelsFlex.jl | 3 +- src/link/checks.jl | 30 +++ src/link/constraint_functions.jl | 1 + src/link/datastructures.jl | 143 +++++++++++++ src/link/model.jl | 110 ++++++++++ test/Project.toml | 1 + test/link/test_CapacityCostLink.jl | 242 ++++++++++++++++++++++ test/runtests.jl | 17 +- test/utils.jl | 16 ++ 16 files changed, 843 insertions(+), 6 deletions(-) create mode 100644 docs/src/links/capacitycostlink.md create mode 100644 examples/capacity_cost_link.jl create mode 100644 src/link/checks.jl create mode 100644 src/link/constraint_functions.jl create mode 100644 src/link/datastructures.jl create mode 100644 src/link/model.jl create mode 100644 test/link/test_CapacityCostLink.jl diff --git a/Project.toml b/Project.toml index e5fee4a..3c0866c 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.2.9" +version = "0.2.10" [deps] EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" diff --git a/docs/make.jl b/docs/make.jl index 43ad973..8745ba8 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -19,6 +19,12 @@ DocMeta.setdocmeta!( news = "docs/src/manual/NEWS.md" cp("NEWS.md", news; force = true) +inputfile = joinpath(@__DIR__, "..", "examples", "flexible_demand.jl") +Literate.markdown(inputfile, joinpath(@__DIR__, "src", "examples")) + +inputfile = joinpath(@__DIR__, "..", "examples", "capacity_cost_link.jl") +Literate.markdown(inputfile, joinpath(@__DIR__, "src", "examples")) + links = InterLinks( "TimeStruct" => "https://sintefore.github.io/TimeStruct.jl/stable/", "EnergyModelsBase" => "https://energymodelsx.github.io/EnergyModelsBase.jl/stable/", @@ -41,6 +47,7 @@ makedocs( "Quick Start"=>"manual/quick-start.md", "Examples"=>Any[ "Flexible demand"=>"examples/flexible_demand.md", + "Capacity cost link"=>"examples/capacity_cost_link.md", ], "Release notes"=>"manual/NEWS.md", ], @@ -60,6 +67,9 @@ makedocs( ], "StorageEfficiency"=>"nodes/storage/storageefficiency.md", ], + "Links" => Any[ + "CapacityCostLink"=>"links/capacitycostlink.md", + ], "How-to" => Any["Contribute"=>"how-to/contribute.md"], "Library" => Any[ diff --git a/docs/src/library/internals/methods-EMB.md b/docs/src/library/internals/methods-EMB.md index 6837e3a..8467221 100644 --- a/docs/src/library/internals/methods-EMB.md +++ b/docs/src/library/internals/methods-EMB.md @@ -10,6 +10,11 @@ Pages = ["methods-EMB.md"] ```@docs EnergyModelsBase.variables_element +EnergyModelsBase.variables_link +EnergyModelsBase.create_link +EnergyModelsBase.capacity +EnergyModelsBase.has_capacity +EnergyModelsBase.has_opex ``` ## [Constraint methods](@id lib-int-met_emb-con) @@ -26,4 +31,5 @@ EnergyModelsBase.constraints_level_aux ```@docs EnergyModelsBase.check_node +EnergyModelsBase.check_link ``` diff --git a/docs/src/library/internals/methods-EMF.md b/docs/src/library/internals/methods-EMF.md index 6f3ca72..29a108b 100644 --- a/docs/src/library/internals/methods-EMF.md +++ b/docs/src/library/internals/methods-EMF.md @@ -11,4 +11,9 @@ Pages = ["methods-EMF.md"] ```@docs EnergyModelsFlex.check_period_ts EnergyModelsFlex.check_limits_default +EnergyModelsFlex.cap_price_periods +EnergyModelsFlex.cap_resource +EnergyModelsFlex.cap_price +EnergyModelsFlex.avg_cap_price +EnergyModelsFlex.create_sub_periods ``` diff --git a/docs/src/library/public.md b/docs/src/library/public.md index 4b7ef87..10ed8f1 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -44,4 +44,12 @@ The following storage node types are implemented in the `EnergyModelsFlex`. ```@docs ElectricBattery StorageEfficiency +``` + +## [`Link` types](@id lib-pub-link) + +The following link types are implemented in the `EnergyModelsFlex`. + +```@docs +CapacityCostLink ``` \ No newline at end of file diff --git a/docs/src/links/capacitycostlink.md b/docs/src/links/capacitycostlink.md new file mode 100644 index 0000000..c5cd233 --- /dev/null +++ b/docs/src/links/capacitycostlink.md @@ -0,0 +1,123 @@ +# [CapacityCostLink](@id links-CapacityCostLink) + +[`CapacityCostLink`](@ref) links model the transport of energy between two nodes with capacity-dependent operational costs applied to a specified resource. +Unlike standard [`Direct`](@extref EnergyModelsBase.Direct) links, they enable cost modeling based on maximum capacity utilization over defined time periods. +This is useful for applications such as transmission networks, pipelines, or interconnectors where usage fees scale with peak capacity demands. + +## [Introduced type and its fields](@id links-CapacityCostLink-fields) + +[`CapacityCostLink`](@ref) is implemented as equivalent to an abstract type [`Link`](@extref EnergyModelsBase.Link). +Hence, it utilizes the same functions declared in `EnergyModelsBase`. + +### [Standard fields](@id links-CapacityCostLink-fields-stand) + +[`CapacityCostLink`](@ref) has the following standard fields, equivalent to a [`Direct`](@extref EnergyModelsBase.Direct) link: + +- **`id`** :\ + The field `id` is only used for providing a name to the link. +- **`from::Node`** :\ + The node from which there is flow into the link. +- **`to::Node`** :\ + The node to which there is flow out of the link. +- **`formulation::Formulation`** :\ + The used formulation of links. + If not specified, a `Linear` link is assumed. + +### [Additional fields](@id links-CapacityCostLink-fields-new) + +The following additional fields are included for [`CapacityCostLink`](@ref) links: + +- **`cap::TimeProfile`** :\ + The maximum capacity of the link for the `cap_resource`. + If the link should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`. + In addition, all values have to be non-negative. +- **`cap_price::TimeProfile`** :\ + The price per unit of maximum capacity usage over the sub-periods. + This value is averaged over sub-periods as defined by `cap_price_periods`. +- **`cap_price_periods::Int64`** :\ + The number of sub-periods within a year for which the capacity cost is calculated. + This allows modeling of varying peak demands across seasons. +- **`cap_resource::Resource`** :\ + The resource for which capacity-dependent costs are applied. + This resource must flow through the link and costs are only associated with this resource. +- **`data::Vector{<:ExtensionData}`**:\ + An entry for providing additional data to the model. + In the current version, it is used for providing additional investment data when [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/) is used. + !!! note "Constructor for `CapacityCostLink`" + The field `data` is not required as we include constructors when the value is excluded. + +## [Mathematical description](@id links-CapacityCostLink-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 paranthesis. + +### [Variables](@id links-CapacityCostLink-math-var) + +#### [Standard variables](@id links-CapacityCostLink-math-var-stand) + +[`CapacityCostLink`](@ref) utilizes standard variables from the [`Link`](@extref EnergyModelsBase.Link) type, as described on the page *[Optimization variables](@extref EnergyModelsBase man-opt_var)*: + +- [``\texttt{link\_in}``](@extref man-opt_var-flow) +- [``\texttt{link\_out}``](@extref man-opt_var-flow) +- [``\texttt{link\_cap\_inst}``](@extref man-opt_var-cap) + +#### [Additional variables](@id links-CapacityCostLink-math-var-add) + +Two additional variables track capacity utilization and associated costs over sub-periods: + +- ``\texttt{max\_cap\_use\_sub\_period}[l, t_{sub}]``: Maximum capacity usage in sub-period ``t_{sub}`` for link ``l``.\ +- ``\texttt{cap\_cost\_sub\_period}[l, t_{sub}]``: Operational cost in sub-period ``t_{sub}`` for link ``l``.\ + +### [Constraints](@id links-CapacityCostLink-math-con) + +#### [Standard constraints](@id links-CapacityCostLink-math-con-stand) + +The applied standard constraint for capacity installed is: + +```math +\texttt{link\_cap\_inst}[l, t] = capacity(l, t) +``` + +and the no-loss constraint + +```math +\texttt{link\_out}[l, t, p] = \texttt{link\_in}[l, t, p] \quad \forall p \in inputs(l) +``` + +#### [Additional constraints](@id links-CapacityCostLink-math-con-add) + +All additional constraints are created within a new method for the function [`create_link`](@extref EnergyModelsBase.create_link). + +The capacity utilization constraint tracks the maximum usage within each sub-period: + +```math +\texttt{link\_in}[l, t, cap\_resource(l)] \leq \texttt{max\_cap\_use\_sub\_period}[l, t_{sub}] +``` + +The capacity cost is calculated as: + +```math +\texttt{cap\_cost\_sub\_period}[l, t_{sub}] = \texttt{max\_cap\_use\_sub\_period}[l, t_{sub}] \times \overline{cap\_price}(l, t_{sub}) +``` + +where ``\overline{cap\_price}`` is the average capacity price over the sub-period. + +Finally, costs are aggregated to each strategic period: + +```math +\texttt{link\_opex\_var}[l, t_{inv}] = \sum_{t_{sub} \in t_{inv}} \texttt{cap\_cost\_sub\_period}[l, t_{sub}] +``` + +In addition, the energy flow of the constrained resource should not exceed the maximum pipe capacity, which is included through the following constraint: + +```math +\texttt{flow\_in}[l, t, cap\_resource(l)] \leq \texttt{link\_cap\_inst}[l, t] +``` diff --git a/examples/capacity_cost_link.jl b/examples/capacity_cost_link.jl new file mode 100644 index 0000000..3773a7b --- /dev/null +++ b/examples/capacity_cost_link.jl @@ -0,0 +1,132 @@ +# # [Capacity cost link](@id examples-capacity_cost_link) +# This example illustrates the usage of [`CapacityCostLink`](@ref links-CapacityCostLink) +# from `EnergyModelsFlex`. +# The example consists of a cheap source, an expensive source, and a sink. Two links connect +# these nodes: a `Direct` link from the expensive source and a `CapacityCostLink` from the cheap source. +# The model compares the operational costs and capacity utilization of these two routing options +# across three strategic periods with varying capacity costs. There is a peak demand in the +# first two operational periods (at 10 and 9 MW) that must be covered followed by low demand (1 MW) +# for the remaining operational periods. Two sub periods are defined for the `CapacityCostLink`, +# allowing it to optimize its capacity usage based on the varying capacity costs over the year. + +# Start by importing the required packages +using TimeStruct +using EnergyModelsBase +using EnergyModelsFlex + +using HiGHS +using JuMP +using PrettyTables + +const EMF = EnergyModelsFlex + +# Define the different resources +power = ResourceCarrier("Power", 0.0) +co2 = ResourceEmit("COβ‚‚", 0.0) +𝒫 = [power, co2] + +# Creation of the time structure and global data +op_number = 24 +𝒯 = TwoLevel([1, 2, 10], SimpleTimes(op_number, 1); op_per_strat = 8760) +modeltype = OperationalModel( + Dict(co2 => FixedProfile(10)), + Dict(co2 => FixedProfile(0)), + co2, +) + +# Create the nodes +src_cheap = RefSource( + "cheap source", + FixedProfile(10), + FixedProfile(100), + FixedProfile(0), + Dict(power => 1), +) +src_exp = RefSource( + "expensive source", + FixedProfile(10), + FixedProfile(400), + FixedProfile(0), + Dict(power => 1), +) +sink = RefSink( + "sink", + OperationalProfile([10, 9, fill(1, op_number-2)...]), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(1e4)), + Dict(power => 1), +) + +# Collect the nodes +𝒩 = [src_cheap, src_exp, sink] + +# Connect the nodes +l_direct = Direct("Direct link", src_exp, sink, Linear()) +l_capacity = CapacityCostLink( + "Capacity cost link", + src_cheap, # from + sink, # to + FixedProfile(10), # capacity + StrategicProfile([5e5, 1e6, 2e6]), # capacity price + 2, # capacity price period + power, # capacity constrained resource +) +β„’ = [l_direct, l_capacity] + +# Input data structure and modeltype creation +case = Case(𝒯, 𝒫, [𝒩, β„’]) + +# Create and optimize the model +optimizer = optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true) +m = create_model(case, modeltype) +set_optimizer(m, optimizer) +optimize!(m) + +# Extract data +demand = value.(m[:cap_use][sink, :]) +direct_flow = [value(m[:link_in][l_direct, t, power]) for t ∈ 𝒯] +capacitycostlink_flow = [value(m[:link_in][l_capacity, t, power]) for t ∈ 𝒯] +periods = collect(𝒯) + +opex_var_cheap = value.(m[:opex_var][src_cheap, :]) +opex_var_expensive = value.(m[:opex_var][src_exp, :]) +link_opex_var = value.(m[:link_opex_var][l_capacity, :]) + +𝒯ⁱⁿᡛ = collect(strategic_periods(𝒯)) +cap_price = [EMF.cap_price(l_capacity)[t] for t ∈ 𝒯ⁱⁿᡛ] + +# ## Display link usage + +# From the table below we see that the `Direct` link is used more +# when the `CapacityCostLink` has a high capacity price, e.g., in strategic periods +# 3. In contrast, when the capacity price is low, e.g., in periods +# 1 and 2, the `CapacityCostLink` is used, but not more than 1 MW as any higher amount would +# result in higher `max_cap_use_sub_period` cost just to be able to cover the two first +# operational periods +pretty_table( + hcat(periods, demand, direct_flow, capacitycostlink_flow); + column_labels = [ + ["Period", "Demand", "Flow", "Flow"], + ["", "Sink", "Direct", "CapacityCostLink"]], + fit_table_in_display_horizontally = false, + fit_table_in_display_vertically = false, + maximum_number_of_rows = -1, + maximum_number_of_columns = -1, +) + +# ## Display operational expenditures + +# From the table below we see that the `CapacityCostLink` is used (has OPEX) only when +# the capacity price is sufficiently low (strategic periods 1 and 2). When the capacity +# price is high (strategic period 3), the `CapacityCostLink` is not used, and the `Direct` +# link (from the expensive source) covers the demand instead. +pretty_table( + hcat(𝒯ⁱⁿᡛ, cap_price, link_opex_var, opex_var_cheap, opex_var_expensive); + column_labels = [ + ["Period", "Capacity Price", "OPEX (link)", "OPEX (cheap node)", "OPEX (expensive node)"], + ["", "CapacityCostLink", "CapacityCostLink", "RefSource", "RefSource"]], + formatters = [fmt__printf("%5.3g")], + fit_table_in_display_horizontally = false, + fit_table_in_display_vertically = false, + maximum_number_of_rows = -1, + maximum_number_of_columns = -1, +) diff --git a/src/EnergyModelsFlex.jl b/src/EnergyModelsFlex.jl index c021fb4..36e3dcf 100644 --- a/src/EnergyModelsFlex.jl +++ b/src/EnergyModelsFlex.jl @@ -18,7 +18,7 @@ const TS = TimeStruct const EMB = EnergyModelsBase const EMR = EnergyModelsRenewableProducers -for node_type ∈ ["source", "sink", "network", "storage"] +for node_type ∈ ["source", "sink", "network", "storage", "link"] include("$node_type/datastructures.jl") include("$node_type/model.jl") include("$node_type/constraint_functions.jl") @@ -29,5 +29,6 @@ export MinUpDownTimeNode, ActivationCostNode, ElectricBattery, LoadShiftingNode export PeriodDemandSink, MultipleInputSink export PayAsProducedPPA, StorageEfficiency, LimitedFlexibleInput, Combustion export ContinuousMultipleInputSinkStrat, BinaryMultipleInputSinkStrat +export CapacityCostLink, HighTempProdNode, ExcessHeat end diff --git a/src/link/checks.jl b/src/link/checks.jl new file mode 100644 index 0000000..f53a7d8 --- /dev/null +++ b/src/link/checks.jl @@ -0,0 +1,30 @@ +""" + EMB.check_link(l::CapacityCostLink, 𝒯, ::EnergyModel, ::Bool) + +This method checks that the *[`CapacityCostLink`](@ref)* link is valid. + +## Checks + - The field `cap` is required to be non-negative. + - The field `cap_price` is required to be non-negative. + - The field `cap_price_period` is required to be positive. +""" +function EMB.check_link(l::CapacityCostLink, 𝒯, ::EnergyModel, ::Bool) + @assert_or_log( + all(capacity(l, t) β‰₯ 0 for t ∈ 𝒯), + "The capacity must be non-negative." + ) + @assert_or_log( + all(cap_price(l)[t] β‰₯ 0 for t ∈ 𝒯), + "The capacity price must be non-negative." + ) + @assert_or_log( + cap_price_periods(l) > 0, + "The the number of sub periods of a year must be positive." + ) + sub_periods = create_sub_periods(𝒯, l) + @assert_or_log( + vcat(sub_periods...) == collect(𝒯), + "The operational period durations could not accumulate into cap_price_periods = + $(cap_price_periods(l)) sub periods of each strategic period." + ) +end diff --git a/src/link/constraint_functions.jl b/src/link/constraint_functions.jl new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/link/constraint_functions.jl @@ -0,0 +1 @@ + diff --git a/src/link/datastructures.jl b/src/link/datastructures.jl new file mode 100644 index 0000000..5eb39ca --- /dev/null +++ b/src/link/datastructures.jl @@ -0,0 +1,143 @@ +""" + CapacityCostLink + +A link between two nodes with costs on the link usage for the resource `cap_resource`. All +other resources have no costs associated with their usage (follows the +[`Direct`](@extref EnergyModelsBase.Direct)). + +# Fields +- **`id`** is the name/identifier of the link. +- **`from::Node`** is the node from which there is flow into the link. +- **`to::Node`** is the node to which there is flow out of the link. +- **`cap::TimeProfile`** is the capacity of the link for the `cap_resource`. +- **`cap_price::TimeProfile`** is the price of capacity usage for the `cap_resource`. +- **`cap_price_periods::Int64`** is the number of sub periods of a year. +- **`cap_resource::Resource`** is the resource used by `CapacityCostLink` +- **`formulation::Formulation`** is the used formulation of links. The field + `formulation` is conditional through usage of a constructor. +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The + field `data` is conditional through usage of a constructor. +""" +struct CapacityCostLink <: EMB.Link + id::Any + from::EMB.Node + to::EMB.Node + cap::TimeProfile + cap_price::TimeProfile + cap_price_periods::Int64 + cap_resource::Resource + formulation::EMB.Formulation + data::Vector{<:ExtensionData} +end + +function CapacityCostLink( + id::Any, + from::EMB.Node, + to::EMB.Node, + cap::TimeProfile, + cap_price::TimeProfile, + cap_price_periods::Int64, + cap_resource::Resource, + formulation::EMB.Formulation, +) + return CapacityCostLink( + id, + from, + to, + cap, + cap_price, + cap_price_periods, + cap_resource, + formulation, + ExtensionData[], + ) +end +function CapacityCostLink( + id::Any, + from::EMB.Node, + to::EMB.Node, + cap::TimeProfile, + cap_price::TimeProfile, + cap_price_periods::Int64, + cap_resource::Resource, + data::Vector{<:ExtensionData}, +) + return CapacityCostLink( + id, + from, + to, + cap, + cap_price, + cap_price_periods, + cap_resource, + Linear(), + data, + ) +end +function CapacityCostLink( + id::Any, + from::EMB.Node, + to::EMB.Node, + cap::TimeProfile, + cap_price::TimeProfile, + cap_price_periods::Int64, + cap_resource::Resource, +) + return CapacityCostLink( + id, + from, + to, + cap, + cap_price, + cap_price_periods, + cap_resource, + Linear(), + ExtensionData[], + ) +end + +""" + has_capacity(l::CapacityCostLink) + +The [`CapacityCostLink`](@ref) has a capacity, and hence, requires the declaration of capacity +variables. +""" +EMB.has_capacity(l::CapacityCostLink) = true + +""" + capacity(l::CapacityCostLink) + capacity(l::CapacityCostLink, t) + +Returns the capacity of a CapacityCostLink `l` as `TimeProfile` or in operational period `t`. +""" +EMB.capacity(l::CapacityCostLink) = l.cap +EMB.capacity(l::CapacityCostLink, t) = l.cap[t] + +""" + has_opex(l::CapacityCostLink) + +A `CapacityCostLink` `l` has operational expenses. +""" +EMB.has_opex(l::CapacityCostLink) = true + +""" + cap_price(l::CapacityCostLink) + +Returns the cap_price of a CapacityCostLink `l`. +""" +cap_price(l::CapacityCostLink) = l.cap_price +cap_price(l::CapacityCostLink, t) = l.cap_price[t] + +""" + cap_price_periods(l::CapacityCostLink) + +Returns the cap_price_periods of a CapacityCostLink `l`. +""" +cap_price_periods(l::CapacityCostLink) = l.cap_price_periods + +""" + cap_resource(l::CapacityCostLink) + +Returns the cap_resource of a CapacityCostLink `l`. +""" +cap_resource(l::CapacityCostLink) = l.cap_resource diff --git a/src/link/model.jl b/src/link/model.jl new file mode 100644 index 0000000..9e31811 --- /dev/null +++ b/src/link/model.jl @@ -0,0 +1,110 @@ +""" + EMB.variables_link(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, modeltype::EnergyModel) + +Creates the following additional variable for **ALL** capacity cost links: +- `max_cap_use_sub_period[l, t]` is a continuous variable describing the maximum capacity + usage over sub periods for a [`CapacityCostLink`](@ref) `l` in operational period `t`. +- `cap_cost_sub_period[l, t]` is a continuous variable describing the cost over sub periods + for a [`CapacityCostLink`](@ref) `l` in operational period `t`. +""" +function EMB.variables_link(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, ::EnergyModel) + @variable(m, max_cap_use_sub_period[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) + @variable(m, cap_cost_sub_period[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) +end + +""" + EMB.create_link(m, l::CapacityCostLink, 𝒯, 𝒫, modeltype::EnergyModel) + +When the link is a [`CapacityCostLink`](@ref), the constraints for a link include +capacity-based cost constraints. + +In addition, a [`CapacityCostLink`](@ref) includes a capacity with the potential for +investments. +""" +function EMB.create_link( + m, + l::CapacityCostLink, + 𝒯, + 𝒫, + modeltype::EnergyModel, +) + # Declaration of the required subsets + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + + power = cap_resource(l) + + # Capacity cost link where output equals input (no losses) + @constraint(m, [t ∈ 𝒯, p ∈ inputs(l)], + m[:link_out][l, t, p] == m[:link_in][l, t, p] + ) + + # Add the capacity constraints + @constraint(m, [t ∈ 𝒯], m[:link_in][l, t, power] ≀ m[:link_cap_inst][l, t]) + constraints_capacity_installed(m, l, 𝒯, modeltype) + + # Create sub-periods based on the user-defined number of sub periods of a year + π’―Λ’α΅˜α΅‡ = create_sub_periods(𝒯, l) + + # Max capacity use constraints + @constraint( + m, + [t_sub ∈ π’―Λ’α΅˜α΅‡, t ∈ t_sub], + m[:link_in][l, t, power] .<= m[:max_cap_use_sub_period][l, t_sub] + ) + + # Capacity cost constraint + @constraint( + m, + [t_sub ∈ π’―Λ’α΅˜α΅‡], + m[:cap_cost_sub_period][l, t_sub[end]] == + m[:max_cap_use_sub_period][l, t_sub[end]] * avg_cap_price(l, t_sub) + ) + + # Sum up costs for each sub_period into the strategic period cost + @constraint( + m, + [t_inv ∈ 𝒯ᴡⁿᡛ], + m[:link_opex_var][l, t_inv] == sum(m[:cap_cost_sub_period][l, t] for t ∈ t_inv) + ) +end + +""" + avg_cap_price(l::CapacityCostLink, t_sub::Vector{TS.TimePeriod}) + +Return the average capacity price over the sub period `t_sub` for the [`CapacityCostLink`](@ref) `l`. +""" +function avg_cap_price(l::CapacityCostLink, t_sub::Vector{<:TS.TimePeriod}) + return sum([cap_price(l, t) for t ∈ t_sub])/length(t_sub) +end + +""" + create_sub_periods(𝒯, l::CapacityCostLink) + +Extract sub periods from the [`CapacityCostLink`](@ref) `l`. +""" +function create_sub_periods(𝒯, l::CapacityCostLink) + # Calculate the length of each sub period + sub_period_duration::Float64 = 𝒯.op_per_strat / cap_price_periods(l) + + # Create a vector collecting all `TimePeriod`s of each sub period into a vector for each + # sub period + sub_periods = Vector{TS.TimePeriod}[] + for t_inv ∈ strategic_periods(𝒯) + accumulated_duration::Float64 = 0.0 + sub_period = TS.TimePeriod[] + for t ∈ t_inv + push!(sub_period, t) + accumulated_duration += duration(t) * multiple_strat(t_inv, t) + + # Check if the accumulated time of the periods in `sub_period` fills up a sub + # period duration + if accumulated_duration β‰ˆ sub_period_duration + push!(sub_periods, sub_period) + sub_period = TS.TimePeriod[] + accumulated_duration = 0 + end + end + end + + return sub_periods +end diff --git a/test/Project.toml b/test/Project.toml index b915244..bfbd80d 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -6,5 +6,6 @@ HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b" JuMP = "4076af6c-e467-56ae-b986-b466b2749572" JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899" LocalRegistry = "89398ba2-070a-4b16-a995-9893c55d93cf" +Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TimeStruct = "f9ed5ce0-9f41-4eaa-96da-f38ab8df101c" diff --git a/test/link/test_CapacityCostLink.jl b/test/link/test_CapacityCostLink.jl new file mode 100644 index 0000000..dc2499a --- /dev/null +++ b/test/link/test_CapacityCostLink.jl @@ -0,0 +1,242 @@ +function capacity_cost_link_test_case(; + cap = FixedProfile(10), + capacity_price = StrategicProfile([5e5, 1e6, 2e6]), + capacity_price_period = 2, +) + # Define the different resources + power = ResourceCarrier("Power", 0.0) + co2 = ResourceEmit("COβ‚‚", 0.0) + 𝒫 = [power, co2] + + # Creation of the time structure and global data + op_number = 24 + 𝒯 = TwoLevel([1, 2, 10], SimpleTimes(op_number, 1); op_per_strat = 8760) + modeltype = OperationalModel( + Dict(co2 => FixedProfile(10)), + Dict(co2 => FixedProfile(0)), + co2, + ) + + # Create the nodes + 𝒩 = [ + RefSource( + "cheap source", + FixedProfile(10), + FixedProfile(100), + FixedProfile(0), + Dict(power => 1), + ), + RefSource( + "expensive source", + FixedProfile(10), + FixedProfile(400), + FixedProfile(0), + Dict(power => 1), + ), + RefSink( + "sink", + OperationalProfile([10, 9, fill(1, op_number-2)...]), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(1e4)), + Dict(power => 1), + ), + ] + + # Connect the nodes + β„’ = [ + Direct("Direct link", 𝒩[2], 𝒩[3], Linear()), + CapacityCostLink( + "Capacity cost link", + 𝒩[1], + 𝒩[3], + cap, + capacity_price, + capacity_price_period, + power, + ), + ] + + # Input data structure and modeltype creation + case = Case(𝒯, 𝒫, [𝒩, β„’]) + + return case, modeltype +end + +@testset "Checks" begin + with_logger(NullLogger()) do + # Test that capacity is non-negative + case, modeltype = capacity_cost_link_test_case(cap = FixedProfile(-5)) + @test_throws AssertionError create_model(case, modeltype) + + # Test that capacity price is non-negative + case, modeltype = + capacity_cost_link_test_case( + capacity_price = StrategicProfile([-1e5, 1e6, 2e6]), + ) + @test_throws AssertionError create_model(case, modeltype) + + # Test that the number of sub periods is positive + case, modeltype = capacity_cost_link_test_case(capacity_price_period = 0) + @test_throws AssertionError create_model(case, modeltype) + + case, modeltype = capacity_cost_link_test_case(capacity_price_period = -1) + @test_throws AssertionError create_model(case, modeltype) + + # Test that operational periods can accumulate into cap_price_periods sub periods + # (8760 is not divisible by 7 sub periods) + case, modeltype = capacity_cost_link_test_case(capacity_price_period = 7) + @test_throws AssertionError create_model(case, modeltype) + end +end + +# Create the case and modeltype +case, modeltype = capacity_cost_link_test_case() + +# Create and optimize the model +optimizer = optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true) +m = create_model(case, modeltype) +set_optimizer(m, optimizer) +optimize!(m) + +# Extract the individual elements and resources +src_cheap, src_exp, sink = get_nodes(case) +direct_link = get_links(case)[1] +l = get_links(case)[2] +power = get_products(case)[1] +𝒯 = get_time_struct(case) +π’―Λ’α΅˜α΅‡ = EMF.create_sub_periods(𝒯, l) +𝒯ᴡⁿᡛ = strategic_periods(𝒯) + +@testset "Utility functions" begin + @testset "EMX functions" begin + # Test the identification functions + @test has_capacity(l) + @test has_opex(l) + + # Test the extraction functions + @test capacity(l) == FixedProfile(10) + @test all(capacity(l, t) == 10 for t ∈ 𝒯) + @test inputs(l) == [power] + @test outputs(l) == [power] + end + + @testset "EMF functions" begin + # Test the extraction functions + capacity_prices = StrategicProfile([5e5, 1e6, 2e6]) + @test all(EMF.cap_price(l)[t_inv] == capacity_prices[t_inv] for t_inv ∈ 𝒯ᴡⁿᡛ) + @test all( + all(EMF.cap_price(l, t) == capacity_prices[t_inv] for t ∈ t_inv) for + t_inv ∈ 𝒯ᴡⁿᡛ + ) + @test EMF.cap_price_periods(l) == 2 + @test EMF.cap_resource(l) == power + end +end + +@testset "Constructor" begin + # Test that the individual constructors are working + l_def = CapacityCostLink( + "Capacity cost link", + src_cheap, + sink, + FixedProfile(10), + FixedProfile(1e6), + 2, + power, + ) + l_data = CapacityCostLink( + "Capacity cost link", + src_cheap, + sink, + FixedProfile(10), + FixedProfile(1e6), + 2, + power, + ExtensionData[], + ) + l_form = CapacityCostLink( + "Capacity cost link", + src_cheap, + sink, + FixedProfile(10), + FixedProfile(1e6), + 2, + power, + Linear(), + ) + l_all = CapacityCostLink( + "Capacity cost link", + src_cheap, + sink, + FixedProfile(10), + FixedProfile(1e6), + 2, + power, + Linear(), + ExtensionData[], + ) + for field ∈ fieldnames(CapacityCostLink) + @test getproperty(l_def, field) == getproperty(l_data, field) + @test getproperty(l_def, field) == getproperty(l_form, field) + @test getproperty(l_def, field) == getproperty(l_all, field) + end +end + +@testset "Constraints" begin + # No losses: link_out == link_in + @test all( + value(m[:link_out][l, t, p]) β‰ˆ value(m[:link_in][l, t, p]) + for t ∈ 𝒯, p ∈ inputs(l) + ) + + # Capacity constraint: link_in ≀ link_cap_inst + @test all( + value(m[:link_in][l, t, power]) ≲ value(m[:link_cap_inst][l, t]) + for t ∈ 𝒯 + ) + + # Max capacity use per sub-period: + # link_in[t] ≀ max_cap_use_sub_period[t_sub_end] + @test all( + all( + value(m[:link_in][l, t, power]) ≲ + value(m[:max_cap_use_sub_period][l, t_sub[end]]) + for t ∈ t_sub + ) + for t_sub ∈ π’―Λ’α΅˜α΅‡ + ) + + # Capacity cost at end of sub-period: cap_cost == max_cap_use * avg_cap_price + @test all( + value(m[:cap_cost_sub_period][l, t_sub[end]]) β‰ˆ + value(m[:max_cap_use_sub_period][l, t_sub[end]]) * EMF.avg_cap_price(l, t_sub) + for t_sub ∈ π’―Λ’α΅˜α΅‡ + ) + + # Strategic-period sum: link_opex_var == sum(cap_cost_sub_period over t_inv) + @test all( + value(m[:link_opex_var][l, t_inv]) β‰ˆ + sum(value(m[:cap_cost_sub_period][l, t]) for t ∈ t_inv) + for t_inv ∈ 𝒯ᴡⁿᡛ + ) + + # Check that there is no sink deficit or surplus + @test all(value.(m[:sink_deficit][sink, t]) β‰ˆ 0.0 for t ∈ 𝒯) + @test all(value.(m[:sink_surplus][sink, t]) β‰ˆ 0.0 for t ∈ 𝒯) + + # Check that the `CapacityCostLink` is only used up to a capacity of 1.0 to limit + # the opex on the line (the remaining demand is covered by the `Direct` link) + @test all(value.(m[:link_out][direct_link, t, power]) β‰ˆ 0.0 for t ∈ π’―Λ’α΅˜α΅‡[2]) + @test all(value.(m[:link_out][l, t, power]) β‰ˆ 1.0 for t ∈ π’―Λ’α΅˜α΅‡[1]) + + # Check that the opex is correct + @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[1]]) β‰ˆ 2 * 5e5 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 5e5 each) + @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[2]]) β‰ˆ 2 * 1e6 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 1e6 each) + @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[3]]) β‰ˆ 0.0 # Due to a high capacity cost of 2e6, the link is not used + + # For the first two operational periods with demand 10 and 9 respectively, the + # Direct link covers the remaining demand (with `1.0` being provided by `l`), which + # with a cost of 400 EUR/MW and scaled with the operational period duration gives: + @test value.(m[:opex_var][src_exp, 𝒯ᴡⁿᡛ[1]]) β‰ˆ ((10-1) + (9-1)) * 400 * (8760/24) + @test value.(m[:opex_var][src_exp, 𝒯ᴡⁿᡛ[2]]) β‰ˆ ((10-1) + (9-1)) * 400 * (8760/24) + @test value.(m[:opex_var][src_exp, 𝒯ᴡⁿᡛ[3]]) β‰ˆ (10 + 9 + (24-2)*1) * 400 * (8760/24) +end diff --git a/test/runtests.jl b/test/runtests.jl index 3d2bc07..1c2d3b2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,6 +5,7 @@ using HiGHS using JuMP using Test using TimeStruct +using Logging const EMB = EnergyModelsBase const EMF = EnergyModelsFlex @@ -17,6 +18,8 @@ const OPTIMIZER = optimizer_with_attributes( MOI.Silent() => true, ) +test_dir = joinpath(pkgdir(EMF), "test") + """ run_node_test(node_supertype::String, node_type::String) @@ -24,18 +27,24 @@ Run the tests for a specific node type. """ function run_node_test(node_supertype::String, node_type::String) @testset "$node_type" begin - include("$node_supertype/test_$(node_type).jl") + include(joinpath(test_dir, "$node_supertype/test_$(node_type).jl")) end end -include("utils.jl") +include(joinpath(test_dir, "utils.jl")) @testset "Flex" begin # Run all Aqua tests - include("Aqua.jl") + include(joinpath(test_dir, "Aqua.jl")) # Check if there is need for formatting - include("JuliaFormatter.jl") + include(joinpath(test_dir, "JuliaFormatter.jl")) + + @testset "Flex | links" begin + for link_type ∈ ["CapacityCostLink"] + run_node_test("link", link_type) + end + end @testset "Flex | Sink nodes" begin for node_type ∈ diff --git a/test/utils.jl b/test/utils.jl index b49eec9..92eed1f 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -1,5 +1,21 @@ using JuMP +const ATOL = 1e-7 + +""" + a ≲ b + +Approximate ≀ comparison with absolute tolerance `ATOL`. +""" +≲(a::Real, b::Real) = a <= b + ATOL + +""" + a ≳ b + +Approximate β‰₯ comparison with absolute tolerance `ATOL`. +""" +≳(a::Real, b::Real) = a + ATOL >= b + function get_values(m, variable, node, iterable) return [JuMP.value(m[variable][node, t]) for t ∈ iterable] end From df758b51881f952eea8fe8b620eefe6e3ba37ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Mon, 22 Dec 2025 19:04:08 +0100 Subject: [PATCH 2/9] Removed `docs/src/example/flexible_demand.md` as the markdown versions of the example files are now generated automatically (and these are thus added to the `.gitignore`-file). --- .gitignore | 1 + docs/src/examples/flexible_demand.md | 160 --------------------------- examples/flexible_demand.jl | 8 +- 3 files changed, 8 insertions(+), 161 deletions(-) delete mode 100644 docs/src/examples/flexible_demand.md diff --git a/.gitignore b/.gitignore index fd6cd1e..4211027 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ *startup.jl docs/build/ docs/src/manual/NEWS.md +docs/src/examples/*.md *.lp *.mps dev/ diff --git a/docs/src/examples/flexible_demand.md b/docs/src/examples/flexible_demand.md deleted file mode 100644 index 8ebc370..0000000 --- a/docs/src/examples/flexible_demand.md +++ /dev/null @@ -1,160 +0,0 @@ -```@meta -EditURL = "../../../examples/flexible_demand.jl" -``` - -# [Flexible demand](@id examples-flexible_demand) -This example uses the following two nodes from `EnergyModelsFlex`: - - [`PeriodDemandSink`](@ref nodes-perioddemandsink) to set a demand per day - instead of per operational period and - - [`MinUpDownTimeNode`](@ref nodes-minupdowntimenode) to force the production - to run for a minimum number of hours if it has first started, and be shut off - for a minimum number of hours if it has first stopped. - -````@example flexible_demand -using EnergyModelsBase -using EnergyModelsFlex -using TimeStruct - -using HiGHS -using JuMP -using PrettyTables -```` - -Declare the required resources. - -````@example flexible_demand -Power = ResourceCarrier("Power", 0) -Product = ResourceCarrier("Product", 0) -CO2 = ResourceEmit("CO2", 0) -```` - -Define a timestructure for a single week. - -````@example flexible_demand -T = TwoLevel(1, 1, SimpleTimes(7 * 24, 1)) -```` - -Some arbitrary electricity prices. Note we let the energy be free in the weekend. -This would be a huge incentive to produce during the weekend, if we allowed the -`PeriodDemandSink` capacity during the weekend. - -````@example flexible_demand -day = [1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2] -el_cost = [repeat(day, 5)..., fill(0, 2 * 24)...] - -grid = RefSource( - "grid", - FixedProfile(1e12), # kW - virtually infinite - OperationalProfile(el_cost), - FixedProfile(0), - Dict(Power => 1), -) -```` - -The production can only run between 6-20 on weekdays, with a capacity of 300 kW. -First, define the maximum capacity for a regular weekday (24 hours). -The capacity is 0 between 0 am and 6 am, 300 kW between 6 am and 8 pm, and 0 again between 8 pm and midnight. - -````@example flexible_demand -weekday_prod = [fill(0, 6)..., fill(300, 14)..., fill(0, 4)...] -@assert length(weekday_prod) == 24 -```` - -Repeat a weekday 5 times, for a workweek, then no production on the weekends. - -````@example flexible_demand -week_prod = [repeat(weekday_prod, 5)..., fill(0, 2 * 24)...] - -demand = PeriodDemandSink( - "demand_product", - 24, # 24 hours per day. - [fill(1500, 5)..., 0, 0], # Demand of 1500 units per day, and nothing (0) in the weekend. - OperationalProfile(week_prod), # kW - installed capacity - Dict(:surplus => FixedProfile(0), :deficit => FixedProfile(1e8)), # € / Demand - Price for not delivering products - Dict(Product => 1), -) -```` - -Define the production line using [`MinUpDownTimeNode`](@ref) - -````@example flexible_demand -min_up_time = 8 -min_down_time = 5 -line = MinUpDownTimeNode( - "line", - FixedProfile(300), # kW - installed capacity for both lines - FixedProfile(0), - FixedProfile(0), - Dict(Power => 1), - Dict(Product => 1), - min_up_time, # minUpTime - min_down_time, # minDownTime - 50, # minCapacity - 300, # maxCapacity - [], -) -```` - -Define the simple energy system - -````@example flexible_demand -nodes = [grid, line, demand] -links = [Direct("grid-line", grid, line), Direct("line-demand", line, demand)] -case = Dict(:T => T, :nodes => nodes, :products => [Power, Product], :links => links) -```` - -Define as operational energy model - -````@example flexible_demand -modeltype = OperationalModel( - Dict(CO2 => FixedProfile(1e6)), - Dict(CO2 => FixedProfile(100)), - CO2, -) -```` - -Optimize the model - -````@example flexible_demand -m = run_model(case, modeltype, HiGHS.Optimizer) -```` - -Show status, should be optimal - -````@example flexible_demand -@show termination_status(m) -```` - -Get the full row table - -````@example flexible_demand -table = JuMP.Containers.rowtable(value, m[:cap_use]; header = [:Node, :TimePeriod, :CapUse]) -```` - -Filter only for `Node == line` - -````@example flexible_demand -line = case[:nodes][2] -filtered = filter(row -> row.Node == line, table) -```` - -Display the filtered table with the resulting optimal production. -- Note that the demand is only satisfied during the set workhours (6-20) on - weekdays. This is cause by the restrictions put on `PeriodDemandSink` with - the capacity limited to these time periods. This is also the reason that - there is no production during the weekend, even though the electricity - is free. -- Also note that the production is always run for at least 8 hours, even - though the daily demand of 1500 units could be reached in 5 hours running at - full capacity. This can be explained by the constraint for minimum run time of 8 - hours on the `MinUpDownTimeNode`. To maximize production at low prices, it - runs at the minimum capacity of 50 when the electricity is more expensive. - -````@example flexible_demand -pretty_table(filtered; crop = :none) -```` - ---- - -*This page was generated using [Literate.jl](https://github.com/fredrikekre/Literate.jl).* - diff --git a/examples/flexible_demand.jl b/examples/flexible_demand.jl index 2ee4cf6..91d8cad 100644 --- a/examples/flexible_demand.jl +++ b/examples/flexible_demand.jl @@ -108,4 +108,10 @@ filtered = filter(row -> row.Node == line, table) # full capacity. This can be explained by the constraint for minimum run time of 8 # hours on the `MinUpDownTimeNode`. To maximize production at low prices, it # runs at the minimum capacity of 50 when the electricity is more expensive. -pretty_table(filtered; crop = :none) +pretty_table( + filtered; + fit_table_in_display_horizontally = false, + fit_table_in_display_vertically = false, + maximum_number_of_rows = -1, + maximum_number_of_columns = -1, +) From 367692a8e85a4db4df9a0a162b124680cdb5d125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Mon, 22 Dec 2025 19:06:09 +0100 Subject: [PATCH 3/9] Removed `ext/EMGUIExt/descriptive_names.yml` as this will now be provided directly in `EnergyModelsGUI` --- ext/EMGUIExt/descriptive_names.yml | 44 ------------------------------ 1 file changed, 44 deletions(-) delete mode 100644 ext/EMGUIExt/descriptive_names.yml diff --git a/ext/EMGUIExt/descriptive_names.yml b/ext/EMGUIExt/descriptive_names.yml deleted file mode 100644 index 282c749..0000000 --- a/ext/EMGUIExt/descriptive_names.yml +++ /dev/null @@ -1,44 +0,0 @@ -# This file contains description of EMX-structures and variables -# with fields of type TimeStruct.TimeProfile -structures: - PayAsProducedPPA: - cap: "Installed capacity" - profile: "Power production in each operational period as a ratio of the installed capacity at that time" - opex_var: "Relative variable operating expense per energy unit produced" - opex_fixed: "Relative fixed operating expense per installed capacity" - LimitedFlexibleInput: - cap: "Installed capacity" - opex_var: "Relative variable operating expense per energy unit produced" - opex_fixed: "Relative fixed operating expense per installed capacity" - Combustion: - cap: "Installed capacity" - opex_var: "Relative variable operating expense per energy unit produced" - opex_fixed: "Relative fixed operating expense per installed capacity" - MultipleInputSink: - cap: "Demand" - penalty: - surplus: "Penalties for surplus" - deficit: "Penalties for deficits" - BinaryMultipleInputSinkStrat: - cap: "Demand" - penalty: - surplus: "Penalties for surplus" - deficit: "Penalties for deficits" - ContinuousMultipleInputSinkStrat: - cap: "Demand" - penalty: - surplus: "Penalties for surplus" - deficit: "Penalties for deficits" - LoadShiftingNode: - cap: "Demand" - penalty: - surplus: "Penalties for surplus" - deficit: "Penalties for deficits" - -variables: - input_frac_strat: "Input resource fraction" - load_shift_from: "Load shift from" - load_shift_to: "Load shift to" - load_shifted: "Load shifted" - sink_surplus_p: "Penalties for surplus of resource" - sink_deficit_p: "Penalties for deficits of resource" \ No newline at end of file From 073f0a60caf162adba1301b7b4c98df067414aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Mon, 22 Dec 2025 20:54:11 +0100 Subject: [PATCH 4/9] Added the new node InflexibleSource --- docs/make.jl | 5 +- docs/src/library/public.md | 1 + docs/src/nodes/source/inflexiblesource.md | 143 ++++++++++++++++++ src/EnergyModelsFlex.jl | 2 +- src/source/constraint_functions.jl | 19 ++- src/source/datastructures.jl | 39 +++++ test/link/test_CapacityCostLink.jl | 2 + test/network/test_ActivationCostNode.jl | 2 + test/network/test_Combustion.jl | 2 + test/network/test_LimitedFlexibleInput.jl | 2 + test/network/test_MinUpDownTimeNode.jl | 2 +- test/runtests.jl | 2 +- .../sink/test_BinaryMultipleInputSinkStrat.jl | 2 + .../test_ContinuousMultipleInputSinkStrat.jl | 2 + test/sink/test_LoadShiftingNode.jl | 2 + test/sink/test_MultipleInputSink.jl | 2 + test/sink/test_PeriodDemandSink.jl | 2 +- test/source/test_InflexibleSource.jl | 72 +++++++++ test/source/test_PayAsProducedPPA.jl | 2 + test/utils.jl | 16 ++ 20 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 docs/src/nodes/source/inflexiblesource.md create mode 100644 test/source/test_InflexibleSource.jl diff --git a/docs/make.jl b/docs/make.jl index 8745ba8..741f700 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -52,7 +52,10 @@ makedocs( "Release notes"=>"manual/NEWS.md", ], "Nodes" => Any[ - "PayAsProducedPPA"=>"nodes/source/payasproducedppa.md", + "Source nodes"=>Any[ + "PayAsProducedPPA" => "nodes/source/payasproducedppa.md", + "InflexibleSource" => "nodes/source/inflexiblesource.md", + ], "Sink nodes"=>Any[ "PeriodDemandSink"=>"nodes/sink/perioddemand.md", "LoadShiftingNode"=>"nodes/sink/loadshiftingnode.md", diff --git a/docs/src/library/public.md b/docs/src/library/public.md index 10ed8f1..1ed2601 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -24,6 +24,7 @@ The following source node type is implemented in the `EnergyModelsFlex`. ```@docs PayAsProducedPPA +InflexibleSource ``` ## [Network `Node` types](@id lib-pub-network-node) diff --git a/docs/src/nodes/source/inflexiblesource.md b/docs/src/nodes/source/inflexiblesource.md new file mode 100644 index 0000000..6730bec --- /dev/null +++ b/docs/src/nodes/source/inflexiblesource.md @@ -0,0 +1,143 @@ +# [Inflexible source node](@id nodes-inflexiblesource) + +Inflexible sources represent energy sources with fixed capacity usage that cannot be varied operationally. +Unlike flexible sources (e.g., like [`RefSource`](@extref EnergyModelsBase.RefSource)) that can adjust output based on system needs, inflexible sources operate at their full installed capacity in every operational period. +Examples include must-run generation units or baseload power plants with operational constraints. + +The [`InflexibleSource`](@ref) is implemented as a simplified variant of the [`RefSource`](@extref EnergyModelsBase.RefSource) that enforces constant capacity utilization. + +## [Introduced type and its fields](@id nodes-inflexiblesource-fields) + +The [`InflexibleSource`](@ref) extends the [`Source`](@extref EnergyModelsBase.Source) type with fixed operational characteristics. + +### [Standard fields](@id nodes-inflexiblesource-fields-stand) + +The [`InflexibleSource`](@ref) has the same standard fields as the [`RefSource`](@extref EnergyModelsBase.RefSource): + +- **`id`**:\ + The field `id` is only used for providing a name to the node. + This is similar to the approach utilized in `EnergyModelsBase`. +- **`cap::TimeProfile`**:\ + The installed capacity corresponds to the forced capacity usage of the node.\ + If the node should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/stable/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`. + In addition, all values have to be non-negative. +- **`opex_var::TimeProfile`**:\ + The variable operational expenses are based on the capacity utilization through the variable [`:cap_use`](@extref EnergyModelsBase man-opt_var-cap). + Hence, it is directly related to the specified `output` ratios. + The variable operating expenses can be provided as `OperationalProfile` as well. +- **`opex_fixed::TimeProfile`**:\ + The fixed operating expenses are relative to the installed capacity (through the field `cap`) and the chosen duration of a strategic period as outlined on *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS)*.\ + It is important to note that you can only use `FixedProfile` or `StrategicProfile` for the fixed OPEX, but not `RepresentativeProfile` or `OperationalProfile`. + In addition, all values have to be non-negative. +- **`output::Dict{<:Resource, <:Real}`**:\ + The field `output` includes [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries. + In the case of a non-dispatchable renewable energy source, `output` should always include your *electricity* resource. In practice, you should use a value of 1.\ + All values have to be non-negative. +- **`data::Vector{Data}`**:\ + An entry for providing additional data to the model. + In the current version, it is only relevant for additional investment data when [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/stable/) is used. + !!! note + The field `data` is not required as we include a constructor when the value is excluded. + + !!! warning "Using `CaptureData`" + If you plan to use [`CaptureData`](@extref EnergyModelsBase.CaptureData) for a [`InflexibleSource`](@ref InflexibleSource) node, it is crucial that you specify your COβ‚‚ resource in the `output` dictionary. + The chosen value is however **not** important as the COβ‚‚ flow is automatically calculated based on the process utilization and the provided process emission value. + The reason for this necessity is that flow variables are declared through the keys of the `output` dictionary. + Hence, not specifying COβ‚‚ as `output` resource results in not creating the corresponding flow variable and subsequent problems in the design. + + We plan to remove this necessity in the future. + As it would most likely correspond to breaking changes, we have to be careful to avoid requiring major changes in other packages. + +## [Mathematical description](@id nodes-inflexiblesource-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 paranthesis. + +### [Variables](@id nodes-inflexiblesource-math-var) + +#### [Standard variables](@id nodes-inflexiblesource-math-var-stand) + +The inflexible source node type utilize all standard variables from the [`RefSource`](@extref EnergyModelsBase.RefSource) node type, 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{emissions\_node}``](@extref EnergyModelsBase man-opt_var-emissions) if `EmissionsData` is added to the field `data`. + +### [Constraints](@id nodes-inflexiblesource-math-con) + +The following sections omit the direct inclusion of the vector of inflexible source nodes. +Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ N^{\text{inflexiblesource}\_source}`` for all [`InflexibleSource`](@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 strategic periods). + +#### [Standard constraints](@id nodes-inflexiblesource-math-con-stand) + +Inflexible source nodes utilize in general the standard constraints described on *[Constraint functions](@extref EnergyModelsBase man-con)*. +In fact, they use the same `create_node` function as a [`RefSource`](@extref EnergyModelsBase.RefSource) node. +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/stable/) to incorporate the potential for investment. + Nodes with investments are then no longer constrained by the parameter capacity. + +- `constraints_flow_out`: + + ```math + \texttt{flow\_out}[n, t, p] = + outputs(n, p) \times \texttt{cap\_use}[n, t] + \qquad \forall p \in outputs(n) \setminus \{\text{CO}_2\} + ``` + +- `constraints_opex_fixed`: + + ```math + \texttt{opex\_fixed}[n, t_{inv}] = opex\_fixed(n, t_{inv}) \times \texttt{cap\_inst}[n, first(t_{inv})] + ``` + + !!! tip "Why do we use `first()`" + The variables ``\texttt{cap\_inst}`` are declared over all operational periods (see the section on *[Capacity variables](@extref EnergyModelsBase man-opt_var-cap)* for further explanations). + Hence, we use the function ``first(t_{inv})`` to retrieve the installed capacities in the first operational period of a given strategic period ``t_{inv}`` in the function `constraints_opex_fixed`. + +- `constraints_opex_var`: + + ```math + \texttt{opex\_var}[n, t_{inv}] = \sum_{t \in t_{inv}} opex\_var(n, t) \times \texttt{cap\_use}[n, t] \times scale\_op\_sp(t_{inv}, t) + ``` + + !!! tip "The function `scale_op_sp`" + The function [``scale\_op\_sp(t_{inv}, t)``](@extref EnergyModelsBase.scale_op_sp) calculates the scaling factor between operational and strategic periods. + It also takes into account potential operational scenarios and their probability as well as representative periods. + +- `constraints_data`:\ + This function is only called for specified additional data, see above. + +The function `constraints_capacity` is extended with a new method for inflexible source nodes to allow the fixing if the ``\texttt{cap\_use}[n, t]`` to the variable ``\texttt{cap\_inst}[n, t]`` +(only replacing inequality with equality compared to [`RefSource`](@extref EnergyModelsBase.RefSource)). +It now includes the individual constraint: + +```math +\texttt{cap\_use}[n, t] = \texttt{cap\_inst}[n, t] +``` + +This function still calls the subfunction `constraints_capacity_installed` to limit the variable ``\texttt{cap\_inst}[n, t]`` or provide capacity investment options. + +#### [Additional constraints](@id nodes-inflexiblesource-math-con-add) + +[`InflexibleSource`](@ref) nodes do not add additional constraints. diff --git a/src/EnergyModelsFlex.jl b/src/EnergyModelsFlex.jl index 36e3dcf..7182d4c 100644 --- a/src/EnergyModelsFlex.jl +++ b/src/EnergyModelsFlex.jl @@ -29,6 +29,6 @@ export MinUpDownTimeNode, ActivationCostNode, ElectricBattery, LoadShiftingNode export PeriodDemandSink, MultipleInputSink export PayAsProducedPPA, StorageEfficiency, LimitedFlexibleInput, Combustion export ContinuousMultipleInputSinkStrat, BinaryMultipleInputSinkStrat -export CapacityCostLink, HighTempProdNode, ExcessHeat +export CapacityCostLink, HighTempProdNode, InflexibleSource end diff --git a/src/source/constraint_functions.jl b/src/source/constraint_functions.jl index 6b6f8f5..4009277 100644 --- a/src/source/constraint_functions.jl +++ b/src/source/constraint_functions.jl @@ -1,5 +1,5 @@ """ - EMB.constraints_opex_var(m, n::PayAsProducedPPA, 𝒯ᴡⁿᡛ, ::EnergyModel) + constraints_opex_var(m, n::PayAsProducedPPA, 𝒯ᴡⁿᡛ, ::EnergyModel) Function for creating the constraint on the variable OPEX of a `PayAsProducedPPA` node. """ @@ -13,3 +13,20 @@ function EMB.constraints_opex_var(m, n::PayAsProducedPPA, 𝒯ᴡⁿᡛ, ::Energ ) ) end + +""" + constraints_capacity(m, n::InflexibleSource, 𝒯::TimeStructure, modeltype::EnergyModel) + +Function for fixing the capacity of a `InflexibleSource` to the installed capacity. +""" +function EMB.constraints_capacity( + m, + n::InflexibleSource, + 𝒯::TimeStructure, + modeltype::EnergyModel, +) + ## Custom constraint for inflexibility + @constraint(m, [t ∈ 𝒯], m[:cap_use][n, t] == m[:cap_inst][n, t]) + + constraints_capacity_installed(m, n, 𝒯, modeltype) +end diff --git a/src/source/datastructures.jl b/src/source/datastructures.jl index abcedfa..50b7e07 100644 --- a/src/source/datastructures.jl +++ b/src/source/datastructures.jl @@ -34,3 +34,42 @@ function PayAsProducedPPA( ) return PayAsProducedPPA(id, cap, profile, opex_var, opex_fixed, output, Data[]) end + +""" + struct InflexibleSource <: EMB.Source + +An inflexible [`Source`](@extref EnergyModelsBase.Source) node with fixed capacity. +The inflexible [`Source`](@extref EnergyModelsBase.Source) node represents a source with a +fixed capacity usage. +Note, that if you include investments, you can only use `cap` as `TimeProfile` a +`FixedProfile` or `StrategicProfile`. + +# Fields +- **`id`** is the name/identifier of the node. +- **`cap::TimeProfile`** is the installed capacity. +- **`opex_var::TimeProfile`** is the variable operating expense per per capacity usage + through the variable `:cap_use`. +- **`opex_fixed::TimeProfile`** is the fixed operating expense per installed capacity + through the variable `:cap_inst`. +- **`output::Dict{<:Resource,<:Real}`** are the generated + [`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 InflexibleSource <: EMB.Source + id::Any + cap::TimeProfile + opex_var::TimeProfile + opex_fixed::TimeProfile + output::Dict{<:Resource,<:Real} + data::Vector{<:ExtensionData} +end +function InflexibleSource( + id::Any, + cap::TimeProfile, + opex_var::TimeProfile, + opex_fixed::TimeProfile, + output::Dict{<:Resource,<:Real}, +) + return InflexibleSource(id, cap, opex_var, opex_fixed, output, ExtensionData[]) +end diff --git a/test/link/test_CapacityCostLink.jl b/test/link/test_CapacityCostLink.jl index dc2499a..0f54c74 100644 --- a/test/link/test_CapacityCostLink.jl +++ b/test/link/test_CapacityCostLink.jl @@ -97,6 +97,8 @@ m = create_model(case, modeltype) set_optimizer(m, optimizer) optimize!(m) +general_tests(m) + # Extract the individual elements and resources src_cheap, src_exp, sink = get_nodes(case) direct_link = get_links(case)[1] diff --git a/test/network/test_ActivationCostNode.jl b/test/network/test_ActivationCostNode.jl index b059ca9..f027ffb 100644 --- a/test/network/test_ActivationCostNode.jl +++ b/test/network/test_ActivationCostNode.jl @@ -107,6 +107,8 @@ end demand = OperationalProfile([10, 20, 30, 0, 30, 0]) m, case, modeltype = act_node_test_case(𝒯; demand) + general_tests(m) + # Extract the values 𝒯 = get_time_struct(case) 𝒩 = get_nodes(case) diff --git a/test/network/test_Combustion.jl b/test/network/test_Combustion.jl index c10688f..cbae7a3 100644 --- a/test/network/test_Combustion.jl +++ b/test/network/test_Combustion.jl @@ -67,6 +67,8 @@ model = OperationalModel( case = Case(𝒯, 𝒫, [𝒩, β„’]) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # Testing the correct source usage for t_inv ∈ 𝒯ᴡⁿᡛ if t_inv.sp == 1 diff --git a/test/network/test_LimitedFlexibleInput.jl b/test/network/test_LimitedFlexibleInput.jl index 4d41e35..7430db7 100644 --- a/test/network/test_LimitedFlexibleInput.jl +++ b/test/network/test_LimitedFlexibleInput.jl @@ -57,6 +57,8 @@ model = OperationalModel( case = Case(𝒯, 𝒫, [𝒩, β„’]) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # Testing the correct source usage for t_inv ∈ 𝒯ᴡⁿᡛ if t_inv.sp == 1 diff --git a/test/network/test_MinUpDownTimeNode.jl b/test/network/test_MinUpDownTimeNode.jl index c797531..aa87e82 100644 --- a/test/network/test_MinUpDownTimeNode.jl +++ b/test/network/test_MinUpDownTimeNode.jl @@ -84,7 +84,7 @@ end m = EnergyModelsBase.run_model(case, model, OPTIMIZER) # Test optimal solution - @test termination_status(m) == MOI.OPTIMAL + general_tests(m) line = get_nodes(case)[2] diff --git a/test/runtests.jl b/test/runtests.jl index 1c2d3b2..b5f6c6a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -60,7 +60,7 @@ include(joinpath(test_dir, "utils.jl")) end @testset "Flex | Source nodes" begin - for node_type ∈ ["PayAsProducedPPA"] + for node_type ∈ ["PayAsProducedPPA", "InflexibleSource"] run_node_test("source", node_type) end end diff --git a/test/sink/test_BinaryMultipleInputSinkStrat.jl b/test/sink/test_BinaryMultipleInputSinkStrat.jl index 26a373d..9e433d4 100644 --- a/test/sink/test_BinaryMultipleInputSinkStrat.jl +++ b/test/sink/test_BinaryMultipleInputSinkStrat.jl @@ -57,6 +57,8 @@ model = OperationalModel( case = Case(𝒯, 𝒫, [𝒩, β„’]) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # Test the correct variable definition and that the variable is a sparse axis array for var ∈ [:input_frac_strat, :sink_surplus_p, :sink_deficit_p] if var == :input_frac_strat diff --git a/test/sink/test_ContinuousMultipleInputSinkStrat.jl b/test/sink/test_ContinuousMultipleInputSinkStrat.jl index 8f20b77..b2f9782 100644 --- a/test/sink/test_ContinuousMultipleInputSinkStrat.jl +++ b/test/sink/test_ContinuousMultipleInputSinkStrat.jl @@ -57,6 +57,8 @@ model = OperationalModel( case = Dict(:T => 𝒯, :nodes => 𝒩, :links => β„’, :products => 𝒫) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # Test the correct variable definition and that the variable is a sparse axis array for var ∈ [:input_frac_strat, :sink_surplus_p, :sink_deficit_p] if var == :input_frac_strat diff --git a/test/sink/test_LoadShiftingNode.jl b/test/sink/test_LoadShiftingNode.jl index aad7636..12acbed 100644 --- a/test/sink/test_LoadShiftingNode.jl +++ b/test/sink/test_LoadShiftingNode.jl @@ -133,6 +133,8 @@ model = OperationalModel( ) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) for t_inv ∈ 𝒯ᴡⁿᡛ @test all( diff --git a/test/sink/test_MultipleInputSink.jl b/test/sink/test_MultipleInputSink.jl index 8a1808f..2e312eb 100644 --- a/test/sink/test_MultipleInputSink.jl +++ b/test/sink/test_MultipleInputSink.jl @@ -40,6 +40,8 @@ model = OperationalModel( case = Case(𝒯, 𝒫, [𝒩, β„’]) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # Testing the correct source usage for t_inv ∈ 𝒯ᴡⁿᡛ if t_inv.sp == 1 diff --git a/test/sink/test_PeriodDemandSink.jl b/test/sink/test_PeriodDemandSink.jl index 78f03ec..ed7ba4a 100644 --- a/test/sink/test_PeriodDemandSink.jl +++ b/test/sink/test_PeriodDemandSink.jl @@ -130,7 +130,7 @@ end optimize!(m) # Test optimal solution - @test termination_status(m) == MOI.OPTIMAL + general_tests(m) demand = get_nodes(case)[2] vals = get_values(m, :cap_use, demand, get_time_struct(case)) diff --git a/test/source/test_InflexibleSource.jl b/test/source/test_InflexibleSource.jl new file mode 100644 index 0000000..7d717ec --- /dev/null +++ b/test/source/test_InflexibleSource.jl @@ -0,0 +1,72 @@ +# Note: No tests for checks are defined for InflexibleSource nodes in EnergyModelsFlex.jl +# since their checks are fully inherited from EnergyModelsBase.jl. + +# Resources used in the analysis +Power = ResourceCarrier("Power", 0.0) +CO2 = ResourceEmit("CO2", 1.0) + +# Function for setting up the system +function simple_graph(source::InflexibleSource, sink::Sink) + resources = [Power, CO2] + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(2, 2, ops; op_per_strat) + + nodes = [source, sink] + links = [Direct(12, source, sink)] + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => FixedProfile(0)), + CO2, + ) + case = Case(T, resources, [nodes, links], [[get_nodes, get_links]]) + return run_model(case, model, HiGHS.Optimizer), case, model +end + +@testset "Constraints" begin + source = InflexibleSource( + "source", + StrategicProfile([ + OperationalProfile([8, 5, 7, 11, 6]), + OperationalProfile([6, 3, 5, 9, 5]), + ]), + FixedProfile(2), + FixedProfile(10), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + OperationalProfile([6, 8, 10, 6, 8]), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(10)), + Dict(Power => 1), + ) + + m, case, model = simple_graph(source, sink) + 𝒯 = get_time_struct(case) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + + general_tests(m) + + # Test that the capacity is properly utilized + # - constraints_capacity(m, n::InflexibleSource, 𝒯::TimeStructure, modeltype::EnergyModel) + @test all( + value.(m[:cap_use][source, t]) β‰ˆ value.(m[:cap_inst][source, t]) for t ∈ 𝒯, + atol ∈ TEST_ATOL + ) + + # Test that sink deficit and surplus values match expected calculations + deficit = StrategicProfile([ + OperationalProfile([0, 3, 3, 0, 2]), + OperationalProfile([0, 5, 5, 0, 3]), + ]) + surplus = StrategicProfile([ + OperationalProfile([2, 0, 0, 5, 0]), + OperationalProfile([0, 0, 0, 3, 0]), + ]) + @test all( + value.(m[:sink_deficit][sink, t]) β‰ˆ deficit[t] for t ∈ 𝒯, atol ∈ TEST_ATOL + ) + @test all( + value.(m[:sink_surplus][sink, t]) β‰ˆ surplus[t] for t ∈ 𝒯, atol ∈ TEST_ATOL + ) +end diff --git a/test/source/test_PayAsProducedPPA.jl b/test/source/test_PayAsProducedPPA.jl index 3010410..da377f7 100644 --- a/test/source/test_PayAsProducedPPA.jl +++ b/test/source/test_PayAsProducedPPA.jl @@ -42,6 +42,8 @@ model = OperationalModel( case = Case(𝒯, 𝒫, [𝒩, β„’]) m = EMB.run_model(case, model, OPTIMIZER) +general_tests(m) + # We only have curtailment in the first strategic period @test sum(value.(m[:curtailment][source_1, t]) > 0 for t ∈ 𝒯) == 3 @test all( diff --git a/test/utils.jl b/test/utils.jl index 92eed1f..bd28a9a 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -20,6 +20,22 @@ function get_values(m, variable, node, iterable) return [JuMP.value(m[variable][node, t]) for t ∈ iterable] end +""" + general_tests(m) + +Perform general tests on the optimization model `m`, such as checking for optimality. +""" +function general_tests(m) + # Check if the solution is optimal. + @testset "optimal solution" begin + @test termination_status(m) == MOI.OPTIMAL + + if termination_status(m) != MOI.OPTIMAL + @show termination_status(m) + end + end +end + function check_cyclic_sequence(arr::Vector, min_up_value::Int, min_down_value::Int)::Bool n = length(arr) From 1b927d2f709ad3033c9cfc320b6a49817144d3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20Vegard=20Ven=C3=A5s?= Date: Fri, 26 Dec 2025 13:55:55 +0100 Subject: [PATCH 5/9] Added the new node FlexibleOutput --- NEWS.md | 13 ++ docs/make.jl | 1 + docs/src/library/internals/methods-EMF.md | 10 +- docs/src/library/internals/methods-fields.md | 15 +++ docs/src/library/public.md | 1 + docs/src/nodes/network/flexibleoutput.md | 135 +++++++++++++++++++ docs/src/nodes/source/inflexiblesource.md | 4 +- src/EnergyModelsFlex.jl | 2 +- src/network/checks.jl | 57 +++++++- src/network/constraint_functions.jl | 22 +++ src/network/datastructures.jl | 60 +++++++++ test/network/test_FlexibleOutput.jl | 134 ++++++++++++++++++ test/runtests.jl | 1 + 13 files changed, 445 insertions(+), 10 deletions(-) create mode 100644 docs/src/nodes/network/flexibleoutput.md create mode 100644 test/network/test_FlexibleOutput.jl diff --git a/NEWS.md b/NEWS.md index 51c413b..faa91ef 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,18 @@ # Release notes +## Version 0.2.10 (2026-01-05) + +### Enhancements + +* Added the new link `CapacityCostLink`. +* Added the nodes `InflexibleSource` and `FlexibleOutput`. + +### Adjustments + +* Removed `ext/EMGUIExt/descriptive_names.yml` as this will now be provided directly in `EnergyModelsGUI`. +* Removed `docs/src/example/flexible_demand.md` as the markdown versions of the example files are now generated automatically (and these are thus added to the `.gitignore`-file). + + ## Version 0.2.9 (2025-07-08) * Adjusted to [`EnergyModelsBase` v0.9.0](https://github.com/EnergyModelsX/EnergyModelsBase.jl/releases/tag/v0.9.0): diff --git a/docs/make.jl b/docs/make.jl index 741f700..e25f00f 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -67,6 +67,7 @@ makedocs( "ActivationCostNode"=>"nodes/network/activationcostnode.md", "LimitedFlexibleInput"=>"nodes/network/limitedflexibleinput.md", "Combustion"=>"nodes/network/combustion.md", + "FlexibleOutput"=>"nodes/network/flexibleoutput.md", ], "StorageEfficiency"=>"nodes/storage/storageefficiency.md", ], diff --git a/docs/src/library/internals/methods-EMF.md b/docs/src/library/internals/methods-EMF.md index 29a108b..79135d1 100644 --- a/docs/src/library/internals/methods-EMF.md +++ b/docs/src/library/internals/methods-EMF.md @@ -11,9 +11,13 @@ Pages = ["methods-EMF.md"] ```@docs EnergyModelsFlex.check_period_ts EnergyModelsFlex.check_limits_default -EnergyModelsFlex.cap_price_periods -EnergyModelsFlex.cap_resource -EnergyModelsFlex.cap_price +EnergyModelsFlex.check_input +EnergyModelsFlex.check_output +``` + +## [Utility functions](@id lib-int-links-fun_utils) + +```@docs EnergyModelsFlex.avg_cap_price EnergyModelsFlex.create_sub_periods ``` diff --git a/docs/src/library/internals/methods-fields.md b/docs/src/library/internals/methods-fields.md index 9a3507d..63ae180 100644 --- a/docs/src/library/internals/methods-fields.md +++ b/docs/src/library/internals/methods-fields.md @@ -21,3 +21,18 @@ EnergyModelsFlex.number_of_periods ```@docs EnergyModelsFlex.activation_consumption ``` + +## [`CapacityCostLink` types](@id lib-int-met_field-CapacityCostLink) + +```@docs +EnergyModelsFlex.cap_price_periods +EnergyModelsFlex.cap_resource +EnergyModelsFlex.cap_price +``` + +## [`Combustion` types](@id lib-int-met_field-Combustion) + +```@docs +EnergyModelsFlex.limits +EnergyModelsFlex.heat_resource +``` \ No newline at end of file diff --git a/docs/src/library/public.md b/docs/src/library/public.md index 1ed2601..7f21aec 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -36,6 +36,7 @@ MinUpDownTimeNode ActivationCostNode LimitedFlexibleInput Combustion +FlexibleOutput ``` ## [Storage `Node` types](@id lib-pub-storage-node) diff --git a/docs/src/nodes/network/flexibleoutput.md b/docs/src/nodes/network/flexibleoutput.md new file mode 100644 index 0000000..0a27301 --- /dev/null +++ b/docs/src/nodes/network/flexibleoutput.md @@ -0,0 +1,135 @@ +# [FlexibleOutput](@id nodes-FlexibleOutput) + +The [`FlexibleOutput`](@ref) node models a conversion technology that can produce **multiple output resources** while sharing a **single capacity limit**. In contrast to a standard [`NetworkNode`](@extref EnergyModelsBase.NetworkNode), the utilized capacity is defined by the **sum of all output flows**, scaled by their respective output conversion factors. + +This formulation enables flexible allocation of production across several co-products (e.g. multiple heat levels or energy carriers) while ensuring that total production remains consistent with the available capacity. + +## [Introduced type and its fields](@id nodes-FlexibleOutput-fields) + +The [`FlexibleOutput`](@ref) is a subtype of the [`NetworkNode`](@extref EnergyModelsBase.NetworkNode). It reuses all standard `NetworkNode` functionality except for the output-flow formulation, which is extended to allow flexible output composition. + +### [Standard fields](@id nodes-FlexibleOutput-fields-stand) + +- **`id`**:\ + The field `id` is only used for providing a name to the node. + This is similar to the approach utilized in `EnergyModelsBase`. + +- **`cap::TimeProfile`**:\ + Specifies the installed capacity. + If the node should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/stable/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`.\ + In addition, all values have to be non-negative. + +- **`opex_var::TimeProfile`**:\ + The variable operational expenses are based on the capacity utilization through the variable [`:cap_use`](@extref EnergyModelsBase man-opt_var-cap). + The variable operating expenses can be provided as `OperationalProfile` as well. + +- **`opex_fixed::TimeProfile`**:\ + The fixed operating expenses are relative to the installed capacity (through the field `cap`) and the chosen duration of a strategic period as outlined on *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS)*.\ + It is important to note that you can only use `FixedProfile` or `StrategicProfile` for the fixed OPEX, but not `RepresentativeProfile` or `OperationalProfile`. + In addition, all values have to be non-negative. + +- **`output::Dict{<:Resource, <:Real}`**:\ + The field `output` includes the output [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries. + All values have to be positive. + +- **`data::Vector{<:ExtensionData}`**:\ + Optional additional data for extensions (e.g. investments or emissions). + !!! note + The field `data` is not required as we include a constructor when the value is excluded. + +## [Mathematical description](@id nodes-FlexibleOutput-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 paranthesis. + +### [Variables](@id nodes-FlexibleOutput-math-var) + +The [`FlexibleOutput`](@ref) node uses the standard `NetworkNode` optimization variables (see *[Optimization variables](@extref EnergyModelsBase man-opt_var)*): + +- [``\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\_in}``](@extref EnergyModelsBase man-opt_var-flow) +- [``\texttt{flow\_out}``](@extref EnergyModelsBase man-opt_var-flow) + +### [Constraints](@id nodes-FlexibleOutput-math-con) + +The following sections omit the direct inclusion of the vector of heat pump nodes. +Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ N^{FlexibleOutput}`` for all [`FlexibleOutput`](@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 strategic periods). + +#### [Standard constraints](@id nodes-FlexibleOutput-math-con-stand) + +[`FlexibleOutput`](@ref) nodes utilize in general the standard constraints described on *[Constraint functions](@extref EnergyModelsBase man-con)* for [`NetworkNode`](@extref EnergyModelsBase.NetworkNode)s, except for the output-flow constraint. + +The following standard constraints apply: + +- `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_capacity`: + + ```math + \texttt{cap\_use}[n, t] \leq \texttt{cap\_inst}[n, t] + ``` + +- `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) + ``` + +- `constraints_opex_fixed`: + + ```math + \texttt{opex\_fixed}[n, t_{inv}] = opex\_fixed(n, t_{inv}) \times + \texttt{cap\_inst}[n, first(t_{inv})] + ``` + + !!! tip "Why do we use `first()`" + The variable ``\texttt{cap\_inst}`` is declared over all operational periods (see the section on *[Capacity variables](@extref EnergyModelsBase man-opt_var-cap)* for further explanations). + Hence, we use the function ``first(t_{inv})`` to retrieve the installed capacity in the first operational period of a given strategic period ``t_{inv}`` in the function `constraints_opex_fixed`. + +- `constraints_opex_var`: + + ```math + \texttt{opex\_var}[n, t_{inv}] = \sum_{t \in t_{inv}} + opex\_var(n, t) \times \texttt{cap\_use}[n, t] + \times scale\_op\_sp(t_{inv}, t) + ``` + + !!! tip "The function `scale_op_sp`" + The function [``scale\_op\_sp(t_{inv}, t)``](@extref EnergyModelsBase.scale_op_sp) calculates the scaling factor between operational and strategic periods. + It also takes into account potential operational scenarios and their probability as well as representative periods. + +- `constraints_ext_data`: + This function is only called if extension data are specified for the node. + +The function `constraints_flow_out` is extended with a new method for flexible output nodes such that the outputs are flexible within their sum being the capacity usage of the node. + +Let ``\mathcal{P}^{out}(n)`` denote the set of output resources of node ``n`` excluding COβ‚‚. The implemented constraint is + +```math +\sum_{p \in \mathcal{P}^{out}(n)} \frac{\texttt{flow\_out}[n, t, p]}{outputs(n, p)} = \texttt{cap\_use}[n, t] +``` + +#### [Additional constraints](@id nodes-FlexibleOutput-math-con-add) + +[`FlexibleOutput`](@ref) nodes do not introduce additional constraint functions beyond the flexible output-flow formulation described above. diff --git a/docs/src/nodes/source/inflexiblesource.md b/docs/src/nodes/source/inflexiblesource.md index 6730bec..39c75f8 100644 --- a/docs/src/nodes/source/inflexiblesource.md +++ b/docs/src/nodes/source/inflexiblesource.md @@ -125,10 +125,10 @@ These standard constraints are: The function [``scale\_op\_sp(t_{inv}, t)``](@extref EnergyModelsBase.scale_op_sp) calculates the scaling factor between operational and strategic periods. It also takes into account potential operational scenarios and their probability as well as representative periods. -- `constraints_data`:\ +- `constraints_data`: This function is only called for specified additional data, see above. -The function `constraints_capacity` is extended with a new method for inflexible source nodes to allow the fixing if the ``\texttt{cap\_use}[n, t]`` to the variable ``\texttt{cap\_inst}[n, t]`` +The function `constraints_capacity` is extended with a new method for inflexible source nodes to allow the fixing of the ``\texttt{cap\_use}[n, t]`` to the variable ``\texttt{cap\_inst}[n, t]`` (only replacing inequality with equality compared to [`RefSource`](@extref EnergyModelsBase.RefSource)). It now includes the individual constraint: diff --git a/src/EnergyModelsFlex.jl b/src/EnergyModelsFlex.jl index 7182d4c..b5ed038 100644 --- a/src/EnergyModelsFlex.jl +++ b/src/EnergyModelsFlex.jl @@ -29,6 +29,6 @@ export MinUpDownTimeNode, ActivationCostNode, ElectricBattery, LoadShiftingNode export PeriodDemandSink, MultipleInputSink export PayAsProducedPPA, StorageEfficiency, LimitedFlexibleInput, Combustion export ContinuousMultipleInputSinkStrat, BinaryMultipleInputSinkStrat -export CapacityCostLink, HighTempProdNode, InflexibleSource +export CapacityCostLink, FlexibleOutput, InflexibleSource end diff --git a/src/network/checks.jl b/src/network/checks.jl index d6ddd40..d9c84f0 100644 --- a/src/network/checks.jl +++ b/src/network/checks.jl @@ -35,11 +35,11 @@ end check_timeprofiles::Bool, ) -This method checks that a `LimitedFlexibleInput` or a `Combustion` node is valid. +This method checks that a `LimitedFlexibleInput` 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 values of the dictionary `input` are required to be positive. - The values of the dictionary `output` are required to be non-negative. - The value of the field `fixed_opex` is required to be non-negative and accessible through a `StrategicPeriod` as outlined in the function @@ -53,6 +53,7 @@ function EMB.check_node( modeltype::EnergyModel, check_timeprofiles::Bool, ) + check_input(n) EMB.check_node_default(n, 𝒯, modeltype, check_timeprofiles) check_limits_default(n) end @@ -60,11 +61,11 @@ end """ EMB.check_node(n::Combustion, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) -This method checks that a `LimitedFlexibleInput` or a `Combustion` node is valid. +This method checks that a `Combustion` 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 values of the dictionary `input` are required to be positive. - The values of the dictionary `output` are required to be non-negative. - The value of the field `fixed_opex` is required to be non-negative and accessible through a `StrategicPeriod` as outlined in the function @@ -74,6 +75,7 @@ This method checks that a `LimitedFlexibleInput` or a `Combustion` node is valid - The resource in the `heat_res` field must be in the dictionary `output`. """ function EMB.check_node(n::Combustion, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) + check_input(n) EMB.check_node_default(n, 𝒯, modeltype, check_timeprofiles) check_limits_default(n) @@ -83,6 +85,29 @@ function EMB.check_node(n::Combustion, 𝒯, modeltype::EnergyModel, check_timep ) end +""" + EMB.check_node(n::FlexibleOutput, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) + +This method checks that a `FlexibleOutput` 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 values of the dictionary `output` are required to be positive. + - The value of the field `fixed_opex` is required to be non-negative and + accessible through a `StrategicPeriod` as outlined in the function + `check_fixed_opex(n, 𝒯ᴡⁿᡛ, check_timeprofiles)`. +""" +function EMB.check_node( + n::FlexibleOutput, + 𝒯, + modeltype::EnergyModel, + check_timeprofiles::Bool, +) + check_output(n) + EMB.check_node_default(n, 𝒯, modeltype, check_timeprofiles) +end + """ check_limits_default(n::Union{LimitedFlexibleInput, Combustion}) @@ -98,3 +123,27 @@ function check_limits_default(n::Union{LimitedFlexibleInput,Combustion}) "The values for the Dictionary `limit` must be non-negative." ) end + +""" + check_input(n::Union{LimitedFlexibleInput, Combustion}) + +This function checks that the input of a `LimitedFlexibleInput` or `Combustion` node are valid. +""" +function check_input(n::Union{LimitedFlexibleInput,Combustion}) + @assert_or_log( + all(inputs(n, p) > 0 for p ∈ inputs(n)), + "The values for the Dictionary `input` must be positive (as they appear in a denominator)." + ) +end + +""" + check_output(n::FlexibleOutput) + +This function checks that the output of a `FlexibleOutput` node are valid. +""" +function check_output(n::FlexibleOutput) + @assert_or_log( + all(outputs(n, p) > 0 for p ∈ outputs(n)), + "The values for the Dictionary `output` must be positive (as they appear in a denominator)." + ) +end diff --git a/src/network/constraint_functions.jl b/src/network/constraint_functions.jl index 368abf0..f01106c 100644 --- a/src/network/constraint_functions.jl +++ b/src/network/constraint_functions.jl @@ -267,3 +267,25 @@ function EMB.constraints_flow_out( m[:flow_out][n, t, p] == m[:cap_use][n, t] * outputs(n, p) ) end + +""" + constraints_flow_out(m, n::FlexibleOutput, 𝒯::TimeStructure, modeltype::EnergyModel) + +Function for creating the constraint on the outlet flow from a `FlexibleOutput`. +""" +function EMB.constraints_flow_out( + m, + n::FlexibleOutput, + 𝒯::TimeStructure, + modeltype::EnergyModel, +) + # Declaration of the required subsets, excluding CO2, if specified + π’«α΅’α΅˜α΅— = EMB.res_not(outputs(n), co2_instance(modeltype)) + + # Definition of custom constraint: node can output multiple resources, but the overall + # output (sum of all resources) cannot be higher than the available capacity + # Constraint for the sum of all output flows to equal cap_use + @constraint(m, [t ∈ 𝒯], + sum(m[:flow_out][n, t, p] / outputs(n, p) for p ∈ π’«α΅’α΅˜α΅—) == m[:cap_use][n, t] + ) +end diff --git a/src/network/datastructures.jl b/src/network/datastructures.jl index ea38094..e447861 100644 --- a/src/network/datastructures.jl +++ b/src/network/datastructures.jl @@ -247,6 +247,66 @@ function Combustion( ) return Combustion(id, cap, opex_var, opex_fixed, limit, heat_res, input, output, Data[]) end + +""" + limits(n::Combustion, p::Resource) + +Returns the limit of a resource `p` for a [`Combustion`](@ref) node `n`. +""" limits(n::Combustion, p::Resource) = n.limit[p] + +""" + limits(n::Combustion) + +Returns all resources with defined limits for a [`Combustion`](@ref) node `n`. +""" limits(n::Combustion) = collect(keys(n.limit)) + +""" + heat_resource(n::Combustion) + +Returns the heat residual resource of a [`Combustion`](@ref) node `n`. +""" heat_resource(n::Combustion) = n.heat_res + +""" + FlexibleOutput <: NetworkNode + +A `FlexibleOutput` node. +The `FlexibleOutput` is similar to [`NetworkNode`](@extref EnergyModelsBase +nodes-network_node)s but introduces flexibility in the output as the capacity use is given +by the sum of these. + +# Fields +- **`id`** is the name/identifier of the node. +- **`cap::TimeProfile`** is the installed capacity. +- **`opex_var::TimeProfile`** is the variable operating expense per per capacity usage + through the variable `:cap_use`. +- **`opex_fixed::TimeProfile`** is the fixed operating expense per installed capacity + through the variable `:cap_inst`. +- **`input::Dict{<:Resource,<:Real}`** are the input [`Resource`](@extref EnergyModelsBase.Resource)s + with conversion value `Real`. +- **`output::Dict{<:Resource,<:Real}`** are the generated [`Resource`](@extref EnergyModelsBase.Resource)s + with conversion value `Real`. +- **`data::Vector{Data}`** is the additional data (*e.g.*, for investments). + The field `data` is conditional through usage of a constructor. +""" +struct FlexibleOutput <: EMB.NetworkNode + id::Any + cap::TimeProfile # Capacity + opex_var::TimeProfile # Variable OPEX in EUR/MWh + opex_fixed::TimeProfile # Fixed OPEX in EUR/h + input::Dict{<:Resource,<:Real} + output::Dict{<:Resource,<:Real} # Output Resource (Heat), leave at 1: COP is calculated seperately + data::Vector{Data} # Optional Investment/Emission Data +end +function FlexibleOutput( + id::Any, + cap::TimeProfile, + opex_var::TimeProfile, + opex_fixed::TimeProfile, + input::Dict{<:Resource,<:Real}, + output::Dict{<:Resource,<:Real}, +) + return FlexibleOutput(id, cap, opex_var, opex_fixed, input, output, Data[]) +end diff --git a/test/network/test_FlexibleOutput.jl b/test/network/test_FlexibleOutput.jl new file mode 100644 index 0000000..d2e52d1 --- /dev/null +++ b/test/network/test_FlexibleOutput.jl @@ -0,0 +1,134 @@ +# Resources used in the analysis +## Input resources +ng = ResourceCarrier("NG", 0.2) +power = ResourceCarrier("Power", 0.0) +CO2 = ResourceEmit("COβ‚‚", 1.0) + +## Output resources: Two products produced by the factory +prod1 = ResourceCarrier("Product 1", 0.0) +prod2 = ResourceCarrier("Product 2", 0.1) + +function flexible_factory_graph() + # Define sources + src_ng = RefSource( + "src_ng", + FixedProfile(200), + FixedProfile(60), + FixedProfile(0.0), + Dict(ng => 1), + ) + + src_power = RefSource( + "src_power", + FixedProfile(200), + FixedProfile(90), + FixedProfile(0.0), + Dict(power => 1), + ) + + # Define FlexibleOutput + # Interpretation: + # - Factory has cap = 10 + # - It can produce either prod1 or prod2 (or both) + # - prod2 is "more intensive": needs twice as much capacity per unit + # + # Constraint enforced: + # prod1/1 + prod2/2 = cap_use + factory = FlexibleOutput( + "factory", + FixedProfile(10), + FixedProfile(0.0), + FixedProfile(0.0), + Dict(ng => 1, power => 1), + Dict(prod1 => 1, prod2 => 2), + [EmissionsEnergy()], + ) + + # Define the sinks (an "external market" for the products) + prod_1_marketprice = 1e3 * OperationalProfile([5, 6, 7, 9, 6]) + prod_2_marketprice = 1e3 * OperationalProfile([9, 2, 7, 8, 5]) + sink_prod1 = RefSink( + "sink_prod1", + FixedProfile(1e5), + Dict(:surplus => -1.0 * prod_1_marketprice, :deficit => prod_1_marketprice), + Dict(prod1 => 1), + ) + + sink_prod2 = RefSink( + "sink_prod2", + FixedProfile(1e5), + Dict(:surplus => -1.0 * prod_2_marketprice, :deficit => prod_2_marketprice), + Dict(prod2 => 1), + [EmissionsEnergy()], + ) + + nodes = [ + src_ng, + src_power, + factory, + sink_prod1, + sink_prod2, + ] + + # Define time structure + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(3, 2, ops; op_per_strat) + + # Define links + links = [ + Direct(1, src_ng, factory), + Direct(2, src_power, factory), + Direct(3, factory, sink_prod1), + Direct(4, factory, sink_prod2), + ] + + resources = [ng, power, prod1, prod2, CO2] + + # Define model + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => StrategicProfile([0, 2e4, 1e5])), + CO2, + ) + + case = Case(T, resources, [nodes, links], [[get_nodes, get_links]]) + return run_model(case, model, HiGHS.Optimizer), case, model +end + +m, case, model = flexible_factory_graph() +𝒯 = get_time_struct(case) +𝒩 = get_nodes(case) + +factory = 𝒩[3] +sink_prod_1 = 𝒩[4] +sink_prod_2 = 𝒩[5] +π’«α΅’α΅˜α΅— = EMB.res_not(outputs(factory), co2_instance(model)) + +general_tests(m) + +@testset "Constraints" begin + # Test that prod1/1 + prod2/2 = cap_use + @test all( + value(m[:cap_use][factory, t]) β‰ˆ sum( + value(m[:flow_out][factory, t, p]) / outputs(factory, p) for + p ∈ outputs(factory) + ) for t ∈ 𝒯 + ) + + # Test that the production matches expected values (no production in sp3 due to high CO2 + # prices and more prod1 production in sp2 due to emissions and higher CO2 prices for prod 2) + expected_prod1 = StrategicProfile([ + OperationalProfile([0, 10, 0, 0, 0]), + OperationalProfile([0, 10, 0, 0, 10]), + OperationalProfile([0, 0, 0, 0, 0]), + ]) + expected_prod2 = StrategicProfile([ + OperationalProfile([20, 0, 20, 20, 20]), + OperationalProfile([20, 0, 20, 20, 0]), + OperationalProfile([0, 0, 0, 0, 0]), + ]) + + @test all(value(m[:cap_use][sink_prod_1, t]) β‰ˆ expected_prod1[t] for t ∈ 𝒯) + @test all(value(m[:cap_use][sink_prod_2, t]) β‰ˆ expected_prod2[t] for t ∈ 𝒯) +end diff --git a/test/runtests.jl b/test/runtests.jl index b5f6c6a..1ed5e06 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -71,6 +71,7 @@ include(joinpath(test_dir, "utils.jl")) "MinUpDownTimeNode", "Combustion", "ActivationCostNode", + "FlexibleOutput", ] run_node_test("network", node_type) end From d0a61d5d45322d7f2638cb9ceaba62420b8a5618 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Fri, 9 Jan 2026 13:52:33 +0100 Subject: [PATCH 6/9] Unified description of `CapacityCostLink` with other nodes --- docs/src/library/internals/methods-EMB.md | 34 +++-- docs/src/library/public.md | 12 +- docs/src/links/capacitycostlink.md | 34 ++++- docs/src/nodes/source/inflexiblesource.md | 2 +- src/link/checks.jl | 6 +- src/link/datastructures.jl | 36 +++-- src/link/model.jl | 58 ++++--- test/link/test_CapacityCostLink.jl | 176 ++++++++++++---------- test/runtests.jl | 12 +- 9 files changed, 207 insertions(+), 163 deletions(-) diff --git a/docs/src/library/internals/methods-EMB.md b/docs/src/library/internals/methods-EMB.md index 8467221..d03b654 100644 --- a/docs/src/library/internals/methods-EMB.md +++ b/docs/src/library/internals/methods-EMB.md @@ -1,4 +1,4 @@ -# [Methods - `EnergyModelsBase`](@id lib-int-met_emb) +# [Methods - `EMB`](@id lib-int-met_emb) ## [Index](@id lib-int-met_emb-idx) @@ -9,27 +9,33 @@ Pages = ["methods-EMB.md"] ## [Extension methods](@id lib-int-met_emb-ext) ```@docs -EnergyModelsBase.variables_element -EnergyModelsBase.variables_link -EnergyModelsBase.create_link -EnergyModelsBase.capacity -EnergyModelsBase.has_capacity -EnergyModelsBase.has_opex +EMB.variables_element +EMB.create_link +EMB.has_capacity +EMB.has_opex ``` ## [Constraint methods](@id lib-int-met_emb-con) ```@docs -EnergyModelsBase.constraints_capacity -EnergyModelsBase.constraints_flow_in -EnergyModelsBase.constraints_flow_out -EnergyModelsBase.constraints_opex_var -EnergyModelsBase.constraints_level_aux +EMB.constraints_capacity +EMB.constraints_flow_in +EMB.constraints_flow_out +EMB.constraints_opex_var +EMB.constraints_level_aux ``` ## [Check methods](@id lib-int-met_emb-check) ```@docs -EnergyModelsBase.check_node -EnergyModelsBase.check_link +EMB.check_node +EMB.check_link +``` + +## [Field extraction methods](@id lib-int-met_emb-field) + +```@docs +EMB.capacity +EMB.inputs +EMB.outputs ``` diff --git a/docs/src/library/public.md b/docs/src/library/public.md index 7f21aec..f58855d 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -8,7 +8,7 @@ Pages = ["public.md"] ## [Sink `Node` types](@id lib-pub-sink-node) -The following sink node types are implemented in the `EnergyModelsFlex`. +The following sink node types are implemented in the `EnergyModelsFlex`: ```@docs PeriodDemandSink @@ -20,7 +20,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 is implemented in the `EnergyModelsFlex`: ```@docs PayAsProducedPPA @@ -29,7 +29,7 @@ InflexibleSource ## [Network `Node` types](@id lib-pub-network-node) -The following network node types are implemented in the `EnergyModelsFlex`. +The following network node types are implemented in the `EnergyModelsFlex`: ```@docs MinUpDownTimeNode @@ -41,7 +41,7 @@ FlexibleOutput ## [Storage `Node` types](@id lib-pub-storage-node) -The following storage node types are implemented in the `EnergyModelsFlex`. +The following storage node types are implemented in the `EnergyModelsFlex`: ```@docs ElectricBattery @@ -50,8 +50,8 @@ StorageEfficiency ## [`Link` types](@id lib-pub-link) -The following link types are implemented in the `EnergyModelsFlex`. +The following link types are implemented in the `EnergyModelsFlex`: ```@docs CapacityCostLink -``` \ No newline at end of file +``` diff --git a/docs/src/links/capacitycostlink.md b/docs/src/links/capacitycostlink.md index c5cd233..21e7f73 100644 --- a/docs/src/links/capacitycostlink.md +++ b/docs/src/links/capacitycostlink.md @@ -4,11 +4,17 @@ Unlike standard [`Direct`](@extref EnergyModelsBase.Direct) links, they enable cost modeling based on maximum capacity utilization over defined time periods. This is useful for applications such as transmission networks, pipelines, or interconnectors where usage fees scale with peak capacity demands. +In addition, they only allow the transport of a single, specified [`Resource`](@extref EnergyModelsBase.Resource). + ## [Introduced type and its fields](@id links-CapacityCostLink-fields) [`CapacityCostLink`](@ref) is implemented as equivalent to an abstract type [`Link`](@extref EnergyModelsBase.Link). Hence, it utilizes the same functions declared in `EnergyModelsBase`. +!!! warning "Application of the link" + The current implementation is not very flexible with respect to the chosen time structure. + Specifically, if you use [`OperationalScenarios`](@extref TimeStruct.OperationalScenarios), [`RepresentativePeriods`](@extref TimeStruct.RepresentativePeriods), or differing operational structures within your [`TwoLevel`](@extref TimeStruct.TwoLevel), you must be careful when choosing the parameter `cap_price_periods`. + ### [Standard fields](@id links-CapacityCostLink-fields-stand) [`CapacityCostLink`](@ref) has the following standard fields, equivalent to a [`Direct`](@extref EnergyModelsBase.Direct) link: @@ -22,24 +28,36 @@ Hence, it utilizes the same functions declared in `EnergyModelsBase`. - **`formulation::Formulation`** :\ The used formulation of links. If not specified, a `Linear` link is assumed. + !!! note "Different formulations" + The current implementation of links does not provide another formulation. + Our aim is in a later stage to allow the user to switch fast through different formulations to increase or decrese the complexity of the model. ### [Additional fields](@id links-CapacityCostLink-fields-new) The following additional fields are included for [`CapacityCostLink`](@ref) links: - **`cap::TimeProfile`** :\ - The maximum capacity of the link for the `cap_resource`. + The maximum transport capacity of the link for the `cap_resource`. If the link should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`. In addition, all values have to be non-negative. - **`cap_price::TimeProfile`** :\ The price per unit of maximum capacity usage over the sub-periods. This value is averaged over sub-periods as defined by `cap_price_periods`. + All values have to be non-negative. - **`cap_price_periods::Int64`** :\ The number of sub-periods within a year for which the capacity cost is calculated. This allows modeling of varying peak demands across seasons. + The value must be positive. + + !!! tip "Number of sub-periods" + For investment periods with many operational periods, consider increasing the number of `cap_price_periods`. + The [`CapacityCostLink`](@ref) capacity constraints couple operational periods and can significantly increase solve time. + Splitting the horizon into multiple sub-periods reduces this coupling and often makes the problem much easier to solve. + In some cases, this also means using more than one capacity price period even if capacity costs occur only annually in reality, depending on model size and complexity. + - **`cap_resource::Resource`** :\ - The resource for which capacity-dependent costs are applied. - This resource must flow through the link and costs are only associated with this resource. + The [`Resource`](@extref EnergyModelsBase.Resource) for which capacity-dependent costs are applied. + This `Resource` is the only transported `Resource` by a [`CapacityCostLink`](@ref). - **`data::Vector{<:ExtensionData}`**:\ An entry for providing additional data to the model. In the current version, it is used for providing additional investment data when [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/) is used. @@ -73,8 +91,8 @@ with paranthesis. Two additional variables track capacity utilization and associated costs over sub-periods: -- ``\texttt{max\_cap\_use\_sub\_period}[l, t_{sub}]``: Maximum capacity usage in sub-period ``t_{sub}`` for link ``l``.\ -- ``\texttt{cap\_cost\_sub\_period}[l, t_{sub}]``: Operational cost in sub-period ``t_{sub}`` for link ``l``.\ +- ``\texttt{ccl\_max\_cap\_use}[l, t_{sub}]``: Maximum capacity usage in sub-period ``t_{sub}`` for link ``l``. +- ``\texttt{ccl\_cap\_cost}[l, t_{sub}]``: Operational cost in sub-period ``t_{sub}`` for link ``l``. ### [Constraints](@id links-CapacityCostLink-math-con) @@ -99,13 +117,13 @@ All additional constraints are created within a new method for the function [`cr The capacity utilization constraint tracks the maximum usage within each sub-period: ```math -\texttt{link\_in}[l, t, cap\_resource(l)] \leq \texttt{max\_cap\_use\_sub\_period}[l, t_{sub}] +\texttt{link\_in}[l, t, cap\_resource(l)] \leq \texttt{ccl\_max\_cap\_use}[l, t_{sub}] ``` The capacity cost is calculated as: ```math -\texttt{cap\_cost\_sub\_period}[l, t_{sub}] = \texttt{max\_cap\_use\_sub\_period}[l, t_{sub}] \times \overline{cap\_price}(l, t_{sub}) +\texttt{ccl\_cap\_cost}[l, t_{sub}] = \texttt{ccl\_max\_cap\_use}[l, t_{sub}] \times \overline{cap\_price}(l, t_{sub}) ``` where ``\overline{cap\_price}`` is the average capacity price over the sub-period. @@ -113,7 +131,7 @@ where ``\overline{cap\_price}`` is the average capacity price over the sub-perio Finally, costs are aggregated to each strategic period: ```math -\texttt{link\_opex\_var}[l, t_{inv}] = \sum_{t_{sub} \in t_{inv}} \texttt{cap\_cost\_sub\_period}[l, t_{sub}] +\texttt{link\_opex\_var}[l, t_{inv}] = \sum_{t_{sub} \in t_{inv}} \texttt{ccl\_cap\_cost}[l, t_{sub}] ``` In addition, the energy flow of the constrained resource should not exceed the maximum pipe capacity, which is included through the following constraint: diff --git a/docs/src/nodes/source/inflexiblesource.md b/docs/src/nodes/source/inflexiblesource.md index 39c75f8..97df767 100644 --- a/docs/src/nodes/source/inflexiblesource.md +++ b/docs/src/nodes/source/inflexiblesource.md @@ -1,7 +1,7 @@ # [Inflexible source node](@id nodes-inflexiblesource) Inflexible sources represent energy sources with fixed capacity usage that cannot be varied operationally. -Unlike flexible sources (e.g., like [`RefSource`](@extref EnergyModelsBase.RefSource)) that can adjust output based on system needs, inflexible sources operate at their full installed capacity in every operational period. +Unlike flexible sources (*e.g.*, like [`RefSource`](@extref EnergyModelsBase.RefSource)) that can adjust output based on system needs, inflexible sources operate at their full installed capacity in every operational period. Examples include must-run generation units or baseload power plants with operational constraints. The [`InflexibleSource`](@ref) is implemented as a simplified variant of the [`RefSource`](@extref EnergyModelsBase.RefSource) that enforces constant capacity utilization. diff --git a/src/link/checks.jl b/src/link/checks.jl index f53a7d8..f2fa055 100644 --- a/src/link/checks.jl +++ b/src/link/checks.jl @@ -21,10 +21,10 @@ function EMB.check_link(l::CapacityCostLink, 𝒯, ::EnergyModel, ::Bool) cap_price_periods(l) > 0, "The the number of sub periods of a year must be positive." ) - sub_periods = create_sub_periods(𝒯, l) + sub_periods = create_sub_periods(l, 𝒯) @assert_or_log( vcat(sub_periods...) == collect(𝒯), - "The operational period durations could not accumulate into cap_price_periods = - $(cap_price_periods(l)) sub periods of each strategic period." + "The operational period durations could not accumulate into `cap_price_periods = + $(cap_price_periods(l))` sub periods of each strategic period." ) end diff --git a/src/link/datastructures.jl b/src/link/datastructures.jl index 5eb39ca..af92fdb 100644 --- a/src/link/datastructures.jl +++ b/src/link/datastructures.jl @@ -2,7 +2,7 @@ CapacityCostLink A link between two nodes with costs on the link usage for the resource `cap_resource`. All -other resources have no costs associated with their usage (follows the +other resources have no costs associated with their usage (follows the [`Direct`](@extref EnergyModelsBase.Direct)). # Fields @@ -97,7 +97,7 @@ function CapacityCostLink( end """ - has_capacity(l::CapacityCostLink) + EMB.has_capacity(l::CapacityCostLink) The [`CapacityCostLink`](@ref) has a capacity, and hence, requires the declaration of capacity variables. @@ -105,25 +105,41 @@ variables. EMB.has_capacity(l::CapacityCostLink) = true """ - capacity(l::CapacityCostLink) - capacity(l::CapacityCostLink, t) + EMB.capacity(l::CapacityCostLink) + EMB.capacity(l::CapacityCostLink, t) -Returns the capacity of a CapacityCostLink `l` as `TimeProfile` or in operational period `t`. +Returns the capacity of a capacity cost link `l` as `TimeProfile` or in operational period `t`. """ EMB.capacity(l::CapacityCostLink) = l.cap EMB.capacity(l::CapacityCostLink, t) = l.cap[t] """ - has_opex(l::CapacityCostLink) + EMB.has_opex(l::CapacityCostLink) A `CapacityCostLink` `l` has operational expenses. """ EMB.has_opex(l::CapacityCostLink) = true +""" + EMB.inputs(l::CapacityCostLink) + +Returns the input resources of a capacity cost link `l`, corresponding to its +[`cap_resource`](@ref). +""" +EMB.inputs(l::CapacityCostLink) = [cap_resource(l)] + +""" + EMB.outputs(l::CapacityCostLink) + +Returns the output resources of a capacity cost link `l`, corresponding to its +[`cap_resource`](@ref). +""" +EMB.outputs(l::CapacityCostLink) = [cap_resource(l)] + """ cap_price(l::CapacityCostLink) -Returns the cap_price of a CapacityCostLink `l`. +Returns the price per unit of maximum capacity usage of a capacity cost link `l`. """ cap_price(l::CapacityCostLink) = l.cap_price cap_price(l::CapacityCostLink, t) = l.cap_price[t] @@ -131,13 +147,15 @@ cap_price(l::CapacityCostLink, t) = l.cap_price[t] """ cap_price_periods(l::CapacityCostLink) -Returns the cap_price_periods of a CapacityCostLink `l`. +Returns the number of sub-periods within a year for which a price is calculated of a capacity +cost link `l`. """ cap_price_periods(l::CapacityCostLink) = l.cap_price_periods """ cap_resource(l::CapacityCostLink) -Returns the cap_resource of a CapacityCostLink `l`. +Returns the resource for which the capacity is limited and has a price of a capacity cost +link `l`. """ cap_resource(l::CapacityCostLink) = l.cap_resource diff --git a/src/link/model.jl b/src/link/model.jl index 9e31811..53d3bcf 100644 --- a/src/link/model.jl +++ b/src/link/model.jl @@ -1,24 +1,24 @@ """ - EMB.variables_link(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, modeltype::EnergyModel) + EMB.variables_element(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, modeltype::EnergyModel) Creates the following additional variable for **ALL** capacity cost links: -- `max_cap_use_sub_period[l, t]` is a continuous variable describing the maximum capacity +- `ccl_max_cap_use[l, t]` is a continuous variable describing the maximum capacity usage over sub periods for a [`CapacityCostLink`](@ref) `l` in operational period `t`. -- `cap_cost_sub_period[l, t]` is a continuous variable describing the cost over sub periods +- `ccl_cap_cost[l, t]` is a continuous variable describing the cost over sub periods for a [`CapacityCostLink`](@ref) `l` in operational period `t`. """ -function EMB.variables_link(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, ::EnergyModel) - @variable(m, max_cap_use_sub_period[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) - @variable(m, cap_cost_sub_period[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) +function EMB.variables_element(m, β„’Λ’α΅˜α΅‡::Vector{<:CapacityCostLink}, 𝒯, ::EnergyModel) + @variable(m, ccl_max_cap_use[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) + @variable(m, ccl_cap_cost[β„’Λ’α΅˜α΅‡, 𝒯] >= 0) end """ EMB.create_link(m, l::CapacityCostLink, 𝒯, 𝒫, modeltype::EnergyModel) -When the link is a [`CapacityCostLink`](@ref), the constraints for a link include +When the link is a [`CapacityCostLink`](@ref), the constraints for a link include capacity-based cost constraints. -In addition, a [`CapacityCostLink`](@ref) includes a capacity with the potential for +In addition, a [`CapacityCostLink`](@ref) includes a capacity with the potential for investments. """ function EMB.create_link( @@ -30,41 +30,35 @@ function EMB.create_link( ) # Declaration of the required subsets 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + p_cap = cap_resource(l) - power = cap_resource(l) + # Create sub-periods based on the user-defined number of sub periods of a year + π’―Λ’α΅˜α΅‡ = create_sub_periods(l, 𝒯) # Capacity cost link where output equals input (no losses) - @constraint(m, [t ∈ 𝒯, p ∈ inputs(l)], - m[:link_out][l, t, p] == m[:link_in][l, t, p] + @constraint(m, [t ∈ 𝒯], + m[:link_out][l, t, p_cap] == m[:link_in][l, t, p_cap] ) # Add the capacity constraints - @constraint(m, [t ∈ 𝒯], m[:link_in][l, t, power] ≀ m[:link_cap_inst][l, t]) + @constraint(m, [t ∈ 𝒯], m[:link_in][l, t, p_cap] ≀ m[:link_cap_inst][l, t]) constraints_capacity_installed(m, l, 𝒯, modeltype) - # Create sub-periods based on the user-defined number of sub periods of a year - π’―Λ’α΅˜α΅‡ = create_sub_periods(𝒯, l) - # Max capacity use constraints - @constraint( - m, - [t_sub ∈ π’―Λ’α΅˜α΅‡, t ∈ t_sub], - m[:link_in][l, t, power] .<= m[:max_cap_use_sub_period][l, t_sub] + @constraint(m, [t_sub ∈ π’―Λ’α΅˜α΅‡, t ∈ t_sub], + m[:link_in][l, t, p_cap] .≀ m[:ccl_max_cap_use][l, t_sub] ) # Capacity cost constraint - @constraint( - m, - [t_sub ∈ π’―Λ’α΅˜α΅‡], - m[:cap_cost_sub_period][l, t_sub[end]] == - m[:max_cap_use_sub_period][l, t_sub[end]] * avg_cap_price(l, t_sub) + @constraint(m, [t_sub ∈ π’―Λ’α΅˜α΅‡], + m[:ccl_cap_cost][l, t_sub[end]] == + m[:ccl_max_cap_use][l, t_sub[end]] * avg_cap_price(l, t_sub) ) # Sum up costs for each sub_period into the strategic period cost - @constraint( - m, - [t_inv ∈ 𝒯ᴡⁿᡛ], - m[:link_opex_var][l, t_inv] == sum(m[:cap_cost_sub_period][l, t] for t ∈ t_inv) + @constraint(m, [t_inv ∈ 𝒯ᴡⁿᡛ], + m[:link_opex_var][l, t_inv] == + sum(m[:ccl_cap_cost][l, t] for t ∈ t_inv) ) end @@ -74,15 +68,15 @@ end Return the average capacity price over the sub period `t_sub` for the [`CapacityCostLink`](@ref) `l`. """ function avg_cap_price(l::CapacityCostLink, t_sub::Vector{<:TS.TimePeriod}) - return sum([cap_price(l, t) for t ∈ t_sub])/length(t_sub) + return sum(cap_price(l, t) * duration(t) for t ∈ t_sub) / sum(duration(t) for t ∈ t_sub) end """ - create_sub_periods(𝒯, l::CapacityCostLink) + create_sub_periods(l::CapacityCostLink, 𝒯) -Extract sub periods from the [`CapacityCostLink`](@ref) `l`. +Extract sub periods of the [`CapacityCostLink`](@ref) `l`. """ -function create_sub_periods(𝒯, l::CapacityCostLink) +function create_sub_periods(l::CapacityCostLink, 𝒯) # Calculate the length of each sub period sub_period_duration::Float64 = 𝒯.op_per_strat / cap_price_periods(l) diff --git a/test/link/test_CapacityCostLink.jl b/test/link/test_CapacityCostLink.jl index 0f54c74..14323ad 100644 --- a/test/link/test_CapacityCostLink.jl +++ b/test/link/test_CapacityCostLink.jl @@ -1,21 +1,18 @@ -function capacity_cost_link_test_case(; +# Declare all resources of the case +power = ResourceCarrier("Power", 0.0) +co2 = ResourceEmit("COβ‚‚", 0.0) + +function capacity_cost_link_case(; cap = FixedProfile(10), capacity_price = StrategicProfile([5e5, 1e6, 2e6]), capacity_price_period = 2, ) # Define the different resources - power = ResourceCarrier("Power", 0.0) - co2 = ResourceEmit("COβ‚‚", 0.0) 𝒫 = [power, co2] - # Creation of the time structure and global data + # Creation of the time structure op_number = 24 𝒯 = TwoLevel([1, 2, 10], SimpleTimes(op_number, 1); op_per_strat = 8760) - modeltype = OperationalModel( - Dict(co2 => FixedProfile(10)), - Dict(co2 => FixedProfile(0)), - co2, - ) # Create the nodes 𝒩 = [ @@ -57,84 +54,77 @@ function capacity_cost_link_test_case(; # Input data structure and modeltype creation case = Case(𝒯, 𝒫, [𝒩, β„’]) + modeltype = OperationalModel( + Dict(co2 => FixedProfile(10)), + Dict(co2 => FixedProfile(0)), + co2, + ) + m = create_model(case, modeltype) - return case, modeltype + return m, case, modeltype end -@testset "Checks" begin - with_logger(NullLogger()) do - # Test that capacity is non-negative - case, modeltype = capacity_cost_link_test_case(cap = FixedProfile(-5)) - @test_throws AssertionError create_model(case, modeltype) - - # Test that capacity price is non-negative - case, modeltype = - capacity_cost_link_test_case( - capacity_price = StrategicProfile([-1e5, 1e6, 2e6]), - ) - @test_throws AssertionError create_model(case, modeltype) - - # Test that the number of sub periods is positive - case, modeltype = capacity_cost_link_test_case(capacity_price_period = 0) - @test_throws AssertionError create_model(case, modeltype) - - case, modeltype = capacity_cost_link_test_case(capacity_price_period = -1) - @test_throws AssertionError create_model(case, modeltype) - - # Test that operational periods can accumulate into cap_price_periods sub periods - # (8760 is not divisible by 7 sub periods) - case, modeltype = capacity_cost_link_test_case(capacity_price_period = 7) - @test_throws AssertionError create_model(case, modeltype) - end -end +# Test that the fields of a `CapacityCostLink` are correctly checked +# - EMB.check_link(l::CapacityCostLink, 𝒯, ::EnergyModel, ::Bool) +@testset "Check functions" begin + # Set the global to true to suppress the error message + EMB.TEST_ENV = true -# Create the case and modeltype -case, modeltype = capacity_cost_link_test_case() + # Test that capacity is non-negative + @test_throws AssertionError capacity_cost_link_case(; cap = FixedProfile(-5)) -# Create and optimize the model -optimizer = optimizer_with_attributes(HiGHS.Optimizer, MOI.Silent() => true) -m = create_model(case, modeltype) -set_optimizer(m, optimizer) -optimize!(m) + # Test that capacity price is non-negative + capacity_price = StrategicProfile([-1e5, 1e6, 2e6]) + @test_throws AssertionError capacity_cost_link_case(; capacity_price) -general_tests(m) + # Test that the number of sub periods is positive + @test_throws AssertionError capacity_cost_link_case(capacity_price_period = 0) + @test_throws AssertionError capacity_cost_link_case(capacity_price_period = -1) -# Extract the individual elements and resources -src_cheap, src_exp, sink = get_nodes(case) -direct_link = get_links(case)[1] -l = get_links(case)[2] -power = get_products(case)[1] -𝒯 = get_time_struct(case) -π’―Λ’α΅˜α΅‡ = EMF.create_sub_periods(𝒯, l) -𝒯ᴡⁿᡛ = strategic_periods(𝒯) + # Test that operational periods can accumulate into cap_price_periods sub periods + # (8760 is not divisible by 7 sub periods) + @test_throws AssertionError capacity_cost_link_case(capacity_price_period = 7) + + # Set the global to true to suppress the error message + EMB.TEST_ENV = false +end @testset "Utility functions" begin + # Create the case and modeltype + m, case, modeltype = capacity_cost_link_case() + cc_link = get_links(case)[2] + 𝒯 = get_time_struct(case) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + @testset "EMX functions" begin # Test the identification functions - @test has_capacity(l) - @test has_opex(l) + @test has_capacity(cc_link) + @test has_opex(cc_link) # Test the extraction functions - @test capacity(l) == FixedProfile(10) - @test all(capacity(l, t) == 10 for t ∈ 𝒯) - @test inputs(l) == [power] - @test outputs(l) == [power] + @test capacity(cc_link) == FixedProfile(10) + @test all(capacity(cc_link, t) == 10 for t ∈ 𝒯) + @test inputs(cc_link) == [power] + @test outputs(cc_link) == [power] end @testset "EMF functions" begin # Test the extraction functions capacity_prices = StrategicProfile([5e5, 1e6, 2e6]) - @test all(EMF.cap_price(l)[t_inv] == capacity_prices[t_inv] for t_inv ∈ 𝒯ᴡⁿᡛ) - @test all( - all(EMF.cap_price(l, t) == capacity_prices[t_inv] for t ∈ t_inv) for - t_inv ∈ 𝒯ᴡⁿᡛ - ) - @test EMF.cap_price_periods(l) == 2 - @test EMF.cap_resource(l) == power + @test all(EMF.cap_price(cc_link)[t_inv] == capacity_prices[t_inv] for t_inv ∈ 𝒯ᴡⁿᡛ) + @test EMF.cap_price(cc_link).vals == capacity_prices.vals + @test EMF.cap_price_periods(cc_link) == 2 + @test EMF.cap_resource(cc_link) == power end end -@testset "Constructor" begin +@testset "Constructor methods" begin + # Create the case and modeltype + m, case, modeltype = capacity_cost_link_case() + + # Extract the individual elements and resources + src_cheap, src_exp, sink = get_nodes(case) + # Test that the individual constructors are working l_def = CapacityCostLink( "Capacity cost link", @@ -183,25 +173,45 @@ end end end -@testset "Constraints" begin +@testset "Constraint implementation" begin + # Create the case and modeltype + m, case, modeltype = capacity_cost_link_case() + + # Optimize the model and conduct the general tests + set_optimizer(m, OPTIMIZER) + optimize!(m) + general_tests(m) + + # Extract the individual elements + src_cheap, src_exp, sink = get_nodes(case) + direct_link, cc_link = get_links(case) + + 𝒯 = get_time_struct(case) + π’―Λ’α΅˜α΅‡ = EMF.create_sub_periods(cc_link, 𝒯) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + # No losses: link_out == link_in @test all( - value(m[:link_out][l, t, p]) β‰ˆ value(m[:link_in][l, t, p]) - for t ∈ 𝒯, p ∈ inputs(l) + value(m[:link_out][cc_link, t, p]) β‰ˆ value(m[:link_in][cc_link, t, p]) + for t ∈ 𝒯, p ∈ inputs(cc_link) ) # Capacity constraint: link_in ≀ link_cap_inst @test all( - value(m[:link_in][l, t, power]) ≲ value(m[:link_cap_inst][l, t]) + value(m[:link_in][cc_link, t, power]) ≲ value(m[:link_cap_inst][cc_link, t]) + for t ∈ 𝒯 + ) + @test all( + value(m[:link_cap_inst][cc_link, t]) β‰ˆ capacity(cc_link, t) for t ∈ 𝒯 ) # Max capacity use per sub-period: - # link_in[t] ≀ max_cap_use_sub_period[t_sub_end] + # link_in[t] ≀ ccl_max_cap_use[t_sub_end] @test all( all( - value(m[:link_in][l, t, power]) ≲ - value(m[:max_cap_use_sub_period][l, t_sub[end]]) + value(m[:link_in][cc_link, t, power]) ≲ + value(m[:ccl_max_cap_use][cc_link, t_sub[end]]) for t ∈ t_sub ) for t_sub ∈ π’―Λ’α΅˜α΅‡ @@ -209,15 +219,15 @@ end # Capacity cost at end of sub-period: cap_cost == max_cap_use * avg_cap_price @test all( - value(m[:cap_cost_sub_period][l, t_sub[end]]) β‰ˆ - value(m[:max_cap_use_sub_period][l, t_sub[end]]) * EMF.avg_cap_price(l, t_sub) + value(m[:ccl_cap_cost][cc_link, t_sub[end]]) β‰ˆ + value(m[:ccl_max_cap_use][cc_link, t_sub[end]]) * EMF.avg_cap_price(cc_link, t_sub) for t_sub ∈ π’―Λ’α΅˜α΅‡ ) - # Strategic-period sum: link_opex_var == sum(cap_cost_sub_period over t_inv) + # Strategic-period sum: link_opex_var == sum(ccl_cap_cost over t_inv) @test all( - value(m[:link_opex_var][l, t_inv]) β‰ˆ - sum(value(m[:cap_cost_sub_period][l, t]) for t ∈ t_inv) + value(m[:link_opex_var][cc_link, t_inv]) β‰ˆ + sum(value(m[:ccl_cap_cost][cc_link, t]) for t ∈ t_inv) for t_inv ∈ 𝒯ᴡⁿᡛ ) @@ -228,15 +238,15 @@ end # Check that the `CapacityCostLink` is only used up to a capacity of 1.0 to limit # the opex on the line (the remaining demand is covered by the `Direct` link) @test all(value.(m[:link_out][direct_link, t, power]) β‰ˆ 0.0 for t ∈ π’―Λ’α΅˜α΅‡[2]) - @test all(value.(m[:link_out][l, t, power]) β‰ˆ 1.0 for t ∈ π’―Λ’α΅˜α΅‡[1]) + @test all(value.(m[:link_out][cc_link, t, power]) β‰ˆ 1.0 for t ∈ π’―Λ’α΅˜α΅‡[1]) # Check that the opex is correct - @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[1]]) β‰ˆ 2 * 5e5 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 5e5 each) - @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[2]]) β‰ˆ 2 * 1e6 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 1e6 each) - @test value.(m[:link_opex_var][l, 𝒯ᴡⁿᡛ[3]]) β‰ˆ 0.0 # Due to a high capacity cost of 2e6, the link is not used + @test value.(m[:link_opex_var][cc_link, 𝒯ᴡⁿᡛ[1]]) β‰ˆ 2 * 5e5 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 5e5 each) + @test value.(m[:link_opex_var][cc_link, 𝒯ᴡⁿᡛ[2]]) β‰ˆ 2 * 1e6 * 1.0 # A capacity of 1.0 is used over both sub periods (having a opex of 1e6 each) + @test value.(m[:link_opex_var][cc_link, 𝒯ᴡⁿᡛ[3]]) β‰ˆ 0.0 # Due to a high capacity cost of 2e6, the link is not used # For the first two operational periods with demand 10 and 9 respectively, the - # Direct link covers the remaining demand (with `1.0` being provided by `l`), which + # Direct link covers the remaining demand (with `1.0` being provided by `cc_link`), which # with a cost of 400 EUR/MW and scaled with the operational period duration gives: @test value.(m[:opex_var][src_exp, 𝒯ᴡⁿᡛ[1]]) β‰ˆ ((10-1) + (9-1)) * 400 * (8760/24) @test value.(m[:opex_var][src_exp, 𝒯ᴡⁿᡛ[2]]) β‰ˆ ((10-1) + (9-1)) * 400 * (8760/24) diff --git a/test/runtests.jl b/test/runtests.jl index 1ed5e06..95f9bcf 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -18,8 +18,6 @@ const OPTIMIZER = optimizer_with_attributes( MOI.Silent() => true, ) -test_dir = joinpath(pkgdir(EMF), "test") - """ run_node_test(node_supertype::String, node_type::String) @@ -27,20 +25,20 @@ Run the tests for a specific node type. """ function run_node_test(node_supertype::String, node_type::String) @testset "$node_type" begin - include(joinpath(test_dir, "$node_supertype/test_$(node_type).jl")) + include("$node_supertype/test_$(node_type).jl") end end -include(joinpath(test_dir, "utils.jl")) +include("utils.jl") @testset "Flex" begin # Run all Aqua tests - include(joinpath(test_dir, "Aqua.jl")) + include("Aqua.jl") # Check if there is need for formatting - include(joinpath(test_dir, "JuliaFormatter.jl")) + # include("JuliaFormatter.jl") - @testset "Flex | links" begin + @testset "Flex | Links" begin for link_type ∈ ["CapacityCostLink"] run_node_test("link", link_type) end From ca563dd9c539f479bd2be32a4f2f07895974146d Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Fri, 9 Jan 2026 14:20:01 +0100 Subject: [PATCH 7/9] Unified description of `InflexibleSource` with other nodes --- docs/src/nodes/source/inflexiblesource.md | 2 +- test/source/test_InflexibleSource.jl | 96 ++++++++++++++++------- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/docs/src/nodes/source/inflexiblesource.md b/docs/src/nodes/source/inflexiblesource.md index 97df767..7db0dbe 100644 --- a/docs/src/nodes/source/inflexiblesource.md +++ b/docs/src/nodes/source/inflexiblesource.md @@ -2,7 +2,7 @@ Inflexible sources represent energy sources with fixed capacity usage that cannot be varied operationally. Unlike flexible sources (*e.g.*, like [`RefSource`](@extref EnergyModelsBase.RefSource)) that can adjust output based on system needs, inflexible sources operate at their full installed capacity in every operational period. -Examples include must-run generation units or baseload power plants with operational constraints. +Examples include must-run generation units, baseload power plants with operational constraints, or heat that must be either cooled down or utilized. The [`InflexibleSource`](@ref) is implemented as a simplified variant of the [`RefSource`](@extref EnergyModelsBase.RefSource) that enforces constant capacity utilization. diff --git a/test/source/test_InflexibleSource.jl b/test/source/test_InflexibleSource.jl index 7d717ec..fc4ac1e 100644 --- a/test/source/test_InflexibleSource.jl +++ b/test/source/test_InflexibleSource.jl @@ -1,29 +1,10 @@ -# Note: No tests for checks are defined for InflexibleSource nodes in EnergyModelsFlex.jl -# since their checks are fully inherited from EnergyModelsBase.jl. - # Resources used in the analysis -Power = ResourceCarrier("Power", 0.0) -CO2 = ResourceEmit("CO2", 1.0) +power = ResourceCarrier("Power", 0.0) +co2 = ResourceEmit("COβ‚‚", 1.0) # Function for setting up the system -function simple_graph(source::InflexibleSource, sink::Sink) - resources = [Power, CO2] - ops = SimpleTimes(5, 2) - op_per_strat = 10 - T = TwoLevel(2, 2, ops; op_per_strat) - - nodes = [source, sink] - links = [Direct(12, source, sink)] - model = OperationalModel( - Dict(CO2 => FixedProfile(100)), - Dict(CO2 => FixedProfile(0)), - CO2, - ) - case = Case(T, resources, [nodes, links], [[get_nodes, get_links]]) - return run_model(case, model, HiGHS.Optimizer), case, model -end +function inflexible_source_case(; output=Dict(power => 1)) -@testset "Constraints" begin source = InflexibleSource( "source", StrategicProfile([ @@ -32,25 +13,82 @@ end ]), FixedProfile(2), FixedProfile(10), - Dict(Power => 1), + output, ) sink = RefSink( "sink", OperationalProfile([6, 8, 10, 6, 8]), Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(10)), - Dict(Power => 1), + Dict(power => 1), + ) + + resources = [power, co2] + ops = SimpleTimes(5, 2) + op_per_strat = 10 + T = TwoLevel(2, 2, ops; op_per_strat) + + nodes = [source, sink] + links = [Direct(12, source, sink)] + modeltype = OperationalModel( + Dict(co2 => FixedProfile(100)), + Dict(co2 => FixedProfile(0)), + co2, ) + case = Case(T, resources, [nodes, links]) + return create_model(case, modeltype), case, modeltype +end + +@testset "Check functions" begin + # Set the global to true to suppress the error message + EMB.TEST_ENV = true + + # Capacity violation + @test_throws AssertionError inflexible_source_case(; output=Dict(power => -1)) - m, case, model = simple_graph(source, sink) + # Set the global to true to suppress the error message + EMB.TEST_ENV = false +end + +@testset "Extraction functions" begin + # Create the model and extract the parameters + m, case, modeltype = inflexible_source_case() + src = get_nodes(case)[1] 𝒯 = get_time_struct(case) - 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + # Test the capacity extraction functions + cap = StrategicProfile([ + OperationalProfile([8, 5, 7, 11, 6]), + OperationalProfile([6, 3, 5, 9, 5]), + ]) + @test all(capacity(src)[t] == cap[t] for t ∈ 𝒯) + @test all(capacity(src, t) == cap[t] for t ∈ 𝒯) + + # Test the output extraction functions + @test outputs(src) == [power] + @test outputs(src, power) == 1 + + # Test the data extraction functions + @test node_data(src) == ExtensionData[] +end + +@testset "Constraint implementation" begin + # Create the case and modeltype + m, case, model = inflexible_source_case() + + # Optimize the model and conduct the general tests + set_optimizer(m, OPTIMIZER) + optimize!(m) general_tests(m) + # Extract the time structure and elements + 𝒯 = get_time_struct(case) + 𝒯ᴡⁿᡛ = strategic_periods(𝒯) + src, snk = get_nodes(case) + # Test that the capacity is properly utilized # - constraints_capacity(m, n::InflexibleSource, 𝒯::TimeStructure, modeltype::EnergyModel) @test all( - value.(m[:cap_use][source, t]) β‰ˆ value.(m[:cap_inst][source, t]) for t ∈ 𝒯, + value.(m[:cap_use][src, t]) β‰ˆ value.(m[:cap_inst][src, t]) for t ∈ 𝒯, atol ∈ TEST_ATOL ) @@ -64,9 +102,9 @@ end OperationalProfile([0, 0, 0, 3, 0]), ]) @test all( - value.(m[:sink_deficit][sink, t]) β‰ˆ deficit[t] for t ∈ 𝒯, atol ∈ TEST_ATOL + value.(m[:sink_deficit][snk, t]) β‰ˆ deficit[t] for t ∈ 𝒯, atol ∈ TEST_ATOL ) @test all( - value.(m[:sink_surplus][sink, t]) β‰ˆ surplus[t] for t ∈ 𝒯, atol ∈ TEST_ATOL + value.(m[:sink_surplus][snk, t]) β‰ˆ surplus[t] for t ∈ 𝒯, atol ∈ TEST_ATOL ) end From e3d1e91b0332dc269a07c176d332d14adfdc58c4 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Fri, 9 Jan 2026 15:11:34 +0100 Subject: [PATCH 8/9] Unified description of `FlexibleOutput` with other nodes --- docs/make.jl | 8 +-- docs/src/library/internals/methods-EMF.md | 1 - docs/src/nodes/network/flexibleoutput.md | 50 +++++++++++------- src/network/checks.jl | 17 ++----- src/network/constraint_functions.jl | 2 +- src/network/datastructures.jl | 15 +++--- test/network/test_FlexibleOutput.jl | 62 +++++++++++++++++------ 7 files changed, 95 insertions(+), 60 deletions(-) diff --git a/docs/make.jl b/docs/make.jl index e25f00f..73cd771 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -19,11 +19,11 @@ DocMeta.setdocmeta!( news = "docs/src/manual/NEWS.md" cp("NEWS.md", news; force = true) -inputfile = joinpath(@__DIR__, "..", "examples", "flexible_demand.jl") -Literate.markdown(inputfile, joinpath(@__DIR__, "src", "examples")) +inputfile = joinpath("examples", "flexible_demand.jl") +Literate.markdown(inputfile, joinpath("docs", "src", "examples")) -inputfile = joinpath(@__DIR__, "..", "examples", "capacity_cost_link.jl") -Literate.markdown(inputfile, joinpath(@__DIR__, "src", "examples")) +inputfile = joinpath("examples", "capacity_cost_link.jl") +Literate.markdown(inputfile, joinpath("docs", "src", "examples")) links = InterLinks( "TimeStruct" => "https://sintefore.github.io/TimeStruct.jl/stable/", diff --git a/docs/src/library/internals/methods-EMF.md b/docs/src/library/internals/methods-EMF.md index 79135d1..680fb9f 100644 --- a/docs/src/library/internals/methods-EMF.md +++ b/docs/src/library/internals/methods-EMF.md @@ -12,7 +12,6 @@ Pages = ["methods-EMF.md"] EnergyModelsFlex.check_period_ts EnergyModelsFlex.check_limits_default EnergyModelsFlex.check_input -EnergyModelsFlex.check_output ``` ## [Utility functions](@id lib-int-links-fun_utils) diff --git a/docs/src/nodes/network/flexibleoutput.md b/docs/src/nodes/network/flexibleoutput.md index 0a27301..8c233fd 100644 --- a/docs/src/nodes/network/flexibleoutput.md +++ b/docs/src/nodes/network/flexibleoutput.md @@ -1,42 +1,53 @@ # [FlexibleOutput](@id nodes-FlexibleOutput) -The [`FlexibleOutput`](@ref) node models a conversion technology that can produce **multiple output resources** while sharing a **single capacity limit**. In contrast to a standard [`NetworkNode`](@extref EnergyModelsBase.NetworkNode), the utilized capacity is defined by the **sum of all output flows**, scaled by their respective output conversion factors. +The [`FlexibleOutput`](@ref) node models a conversion technology that can produce **multiple output resources** while sharing a **single capacity limit**. +In contrast to a standard [`NetworkNode`](@extref EnergyModelsBase.NetworkNode), the utilized capacity is defined by the **sum of all output flows**, scaled by their respective output conversion factors. -This formulation enables flexible allocation of production across several co-products (e.g. multiple heat levels or energy carriers) while ensuring that total production remains consistent with the available capacity. +This formulation enables flexible allocation of production across several co-products (*e.g.*, multiple heat levels or energy carriers) while ensuring that total production remains consistent with the available capacity. ## [Introduced type and its fields](@id nodes-FlexibleOutput-fields) -The [`FlexibleOutput`](@ref) is a subtype of the [`NetworkNode`](@extref EnergyModelsBase.NetworkNode). It reuses all standard `NetworkNode` functionality except for the output-flow formulation, which is extended to allow flexible output composition. +The [`FlexibleOutput`](@ref) is a subtype of the [`NetworkNode`](@extref EnergyModelsBase.NetworkNode). +It reuses all standard `NetworkNode` functionality except for the output-flow formulation, which is extended to allow flexible output composition. ### [Standard fields](@id nodes-FlexibleOutput-fields-stand) +The standard fields of a [`FlexibleOutput`](@ref) node are given as: + - **`id`**:\ The field `id` is only used for providing a name to the node. - This is similar to the approach utilized in `EnergyModelsBase`. - - **`cap::TimeProfile`**:\ - Specifies the installed capacity. - If the node should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/stable/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`.\ + The installed capacity corresponds to the nominal capacity of the node.\ + If the node should contain investments through the application of [`EnergyModelsInvestments`](https://energymodelsx.github.io/EnergyModelsInvestments.jl/), it is important to note that you can only use `FixedProfile` or `StrategicProfile` for the capacity, but not `RepresentativeProfile` or `OperationalProfile`. In addition, all values have to be non-negative. - - **`opex_var::TimeProfile`**:\ The variable operational expenses are based on the capacity utilization through the variable [`:cap_use`](@extref EnergyModelsBase man-opt_var-cap). + Hence, it is directly related to the specified `input` and `output` ratios. The variable operating expenses can be provided as `OperationalProfile` as well. - - **`opex_fixed::TimeProfile`**:\ - The fixed operating expenses are relative to the installed capacity (through the field `cap`) and the chosen duration of a strategic period as outlined on *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS)*.\ + The fixed operating expenses are relative to the installed capacity (through the field `cap`) and the chosen duration of an investment period as outlined on *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS)*.\ It is important to note that you can only use `FixedProfile` or `StrategicProfile` for the fixed OPEX, but not `RepresentativeProfile` or `OperationalProfile`. In addition, all values have to be non-negative. - -- **`output::Dict{<:Resource, <:Real}`**:\ - The field `output` includes the output [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries. - All values have to be positive. - +- **`input::Dict{<:Resource,<:Real}`** and **`output::Dict{<:Resource,<:Real}`**:\ + Both fields describe the `input` and `output` [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries.\ + COβ‚‚ cannot be directly specified, *i.e.*, you cannot specify a ratio. + If you use [`CaptureData`](@extref EnergyModelsBase.CaptureData), it is however necessary to specify COβ‚‚ as output, although the ratio is not important.\ + All values have to be non-negative. - **`data::Vector{<:ExtensionData}`**:\ - Optional additional data for extensions (e.g. investments or emissions). - !!! note + 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 "Constructor for `NewNetworkNode`" The field `data` is not required as we include a constructor when the value is excluded. + !!! warning "Using `CaptureData`" + If you plan to use [`CaptureData`](@extref EnergyModelsBase.CaptureData) for a [`NewNetworkNode`] node, it is crucial that you specify your COβ‚‚ resource in the `output` dictionary. + The chosen value is however **not** important as the COβ‚‚ flow is automatically calculated based on the process utilization and the provided process emission value. + The reason for this necessity is that flow variables are declared through the keys of the `output` dictionary. + Hence, not specifying COβ‚‚ as `output` resource results in not creating the corresponding flow variable and subsequent problems in the design. + + We plan to remove this necessity in the future. + As it would most likely correspond to breaking changes, we have to be careful to avoid requiring major changes in other packages. + ## [Mathematical description](@id nodes-FlexibleOutput-math) In the following mathematical equations, we use the name for variables and functions used in the model. @@ -63,7 +74,7 @@ The [`FlexibleOutput`](@ref) node uses the standard `NetworkNode` optimization v ### [Constraints](@id nodes-FlexibleOutput-math-con) -The following sections omit the direct inclusion of the vector of heat pump nodes. +The following sections omit the direct inclusion of the vector of flexible output nodes. Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ N^{FlexibleOutput}`` for all [`FlexibleOutput`](@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 strategic periods). @@ -124,7 +135,8 @@ The following standard constraints apply: The function `constraints_flow_out` is extended with a new method for flexible output nodes such that the outputs are flexible within their sum being the capacity usage of the node. -Let ``\mathcal{P}^{out}(n)`` denote the set of output resources of node ``n`` excluding COβ‚‚. The implemented constraint is +Let ``\mathcal{P}^{out}(n)`` denote the set of output resources of node ``n`` excluding COβ‚‚, obtained through the function [`soutputs`](@extref EnergyModelsBase.outputs). +The implemented constraint is then given by ```math \sum_{p \in \mathcal{P}^{out}(n)} \frac{\texttt{flow\_out}[n, t, p]}{outputs(n, p)} = \texttt{cap\_use}[n, t] diff --git a/src/network/checks.jl b/src/network/checks.jl index d9c84f0..80a43a8 100644 --- a/src/network/checks.jl +++ b/src/network/checks.jl @@ -104,7 +104,10 @@ function EMB.check_node( modeltype::EnergyModel, check_timeprofiles::Bool, ) - check_output(n) + @assert_or_log( + all(outputs(n, p) > 0 for p ∈ outputs(n)), + "The values for the Dictionary `output` must be positive (as they appear in a denominator)." + ) EMB.check_node_default(n, 𝒯, modeltype, check_timeprofiles) end @@ -135,15 +138,3 @@ function check_input(n::Union{LimitedFlexibleInput,Combustion}) "The values for the Dictionary `input` must be positive (as they appear in a denominator)." ) end - -""" - check_output(n::FlexibleOutput) - -This function checks that the output of a `FlexibleOutput` node are valid. -""" -function check_output(n::FlexibleOutput) - @assert_or_log( - all(outputs(n, p) > 0 for p ∈ outputs(n)), - "The values for the Dictionary `output` must be positive (as they appear in a denominator)." - ) -end diff --git a/src/network/constraint_functions.jl b/src/network/constraint_functions.jl index f01106c..773e6da 100644 --- a/src/network/constraint_functions.jl +++ b/src/network/constraint_functions.jl @@ -284,7 +284,7 @@ function EMB.constraints_flow_out( # Definition of custom constraint: node can output multiple resources, but the overall # output (sum of all resources) cannot be higher than the available capacity - # Constraint for the sum of all output flows to equal cap_use + # Constraint for the sum of all output flows to equal `:cap_use` @constraint(m, [t ∈ 𝒯], sum(m[:flow_out][n, t, p] / outputs(n, p) for p ∈ π’«α΅’α΅˜α΅—) == m[:cap_use][n, t] ) diff --git a/src/network/datastructures.jl b/src/network/datastructures.jl index e447861..ff397ba 100644 --- a/src/network/datastructures.jl +++ b/src/network/datastructures.jl @@ -273,8 +273,9 @@ heat_resource(n::Combustion) = n.heat_res FlexibleOutput <: NetworkNode A `FlexibleOutput` node. -The `FlexibleOutput` is similar to [`NetworkNode`](@extref EnergyModelsBase -nodes-network_node)s but introduces flexibility in the output as the capacity use is given + +The `FlexibleOutput` is similar to [`NetworkNode`](@extref EnergyModelsBase +nodes-network_node)s but introduces flexibility in the output as the capacity use is given by the sum of these. # Fields @@ -293,12 +294,12 @@ by the sum of these. """ struct FlexibleOutput <: EMB.NetworkNode id::Any - cap::TimeProfile # Capacity - opex_var::TimeProfile # Variable OPEX in EUR/MWh - opex_fixed::TimeProfile # Fixed OPEX in EUR/h + cap::TimeProfile + opex_var::TimeProfile + opex_fixed::TimeProfile input::Dict{<:Resource,<:Real} - output::Dict{<:Resource,<:Real} # Output Resource (Heat), leave at 1: COP is calculated seperately - data::Vector{Data} # Optional Investment/Emission Data + output::Dict{<:Resource,<:Real} + data::Vector{Data} end function FlexibleOutput( id::Any, diff --git a/test/network/test_FlexibleOutput.jl b/test/network/test_FlexibleOutput.jl index d2e52d1..1f3ed0c 100644 --- a/test/network/test_FlexibleOutput.jl +++ b/test/network/test_FlexibleOutput.jl @@ -8,7 +8,7 @@ CO2 = ResourceEmit("COβ‚‚", 1.0) prod1 = ResourceCarrier("Product 1", 0.0) prod2 = ResourceCarrier("Product 2", 0.1) -function flexible_factory_graph() +function flexible_factory_case(; cap=FixedProfile(10)) # Define sources src_ng = RefSource( "src_ng", @@ -36,9 +36,9 @@ function flexible_factory_graph() # prod1/1 + prod2/2 = cap_use factory = FlexibleOutput( "factory", - FixedProfile(10), - FixedProfile(0.0), - FixedProfile(0.0), + cap, + FixedProfile(0.2), + FixedProfile(0.1), Dict(ng => 1, power => 1), Dict(prod1 => 1, prod2 => 2), [EmissionsEnergy()], @@ -86,28 +86,60 @@ function flexible_factory_graph() resources = [ng, power, prod1, prod2, CO2] # Define model - model = OperationalModel( + modeltype = OperationalModel( Dict(CO2 => FixedProfile(100)), Dict(CO2 => StrategicProfile([0, 2e4, 1e5])), CO2, ) case = Case(T, resources, [nodes, links], [[get_nodes, get_links]]) - return run_model(case, model, HiGHS.Optimizer), case, model + return create_model(case, modeltype), case, modeltype +end + + +@testset "Check functions" begin + # Set the global to true to suppress the error message + EMB.TEST_ENV = true + + # Capacity violation + @test_throws AssertionError flexible_factory_case(; cap=FixedProfile(-5)) + + # Set the global to true to suppress the error message + EMB.TEST_ENV = false +end + + +@testset "Extraction functions" begin + # Create the model and extract the parameters + m, case, modeltype = flexible_factory_case() + factory = get_nodes(case)[3] + 𝒯 = get_time_struct(case) + + # Test the EMB extraction functions + @test capacity(factory) == FixedProfile(10) + @test opex_var(factory) == FixedProfile(0.2) + @test opex_fixed(factory) == FixedProfile(0.1) + @test inputs(factory) == [ng, power] || inputs(factory) == [power, ng] + @test outputs(factory) == [prod1, prod2] || outputs(factory) == [prod2, prod1] + @test node_data(factory) == ExtensionData[EmissionsEnergy()] end -m, case, model = flexible_factory_graph() -𝒯 = get_time_struct(case) -𝒩 = get_nodes(case) +@testset "Constraint implementation" begin + # Create the case and modeltype + m, case, modeltype = flexible_factory_case() -factory = 𝒩[3] -sink_prod_1 = 𝒩[4] -sink_prod_2 = 𝒩[5] -π’«α΅’α΅˜α΅— = EMB.res_not(outputs(factory), co2_instance(model)) + # Optimize the model and conduct the general tests + set_optimizer(m, OPTIMIZER) + optimize!(m) + general_tests(m) -general_tests(m) + # Extract the time structure and elements + 𝒯 = get_time_struct(case) + 𝒩 = get_nodes(case) + factory = 𝒩[3] + sink_prod_1 = 𝒩[4] + sink_prod_2 = 𝒩[5] -@testset "Constraints" begin # Test that prod1/1 + prod2/2 = cap_use @test all( value(m[:cap_use][factory, t]) β‰ˆ sum( From 7761c36227b028912db73d777ffbca2816f52173 Mon Sep 17 00:00:00 2001 From: Julian Straus Date: Mon, 12 Jan 2026 13:40:36 +0100 Subject: [PATCH 9/9] Updated documentation to address comments --- docs/src/nodes/network/flexibleoutput.md | 13 ++++++++++--- test/network/test_FlexibleOutput.jl | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/src/nodes/network/flexibleoutput.md b/docs/src/nodes/network/flexibleoutput.md index 8c233fd..d08ec36 100644 --- a/docs/src/nodes/network/flexibleoutput.md +++ b/docs/src/nodes/network/flexibleoutput.md @@ -33,14 +33,21 @@ The standard fields of a [`FlexibleOutput`](@ref) node are given as: COβ‚‚ cannot be directly specified, *i.e.*, you cannot specify a ratio. If you use [`CaptureData`](@extref EnergyModelsBase.CaptureData), it is however necessary to specify COβ‚‚ as output, although the ratio is not important.\ All values have to be non-negative. + !!! tip "Conversion factor" + The conversion factor for the `input` is used as a multiplier for the capacity usage. + A value of ``2`` implies that you need 2 MW input resource/MW capacity usage.\ + The conversion factor for the `output` is also used as a multiplier for the capacity usage. + A value of ``2`` implies that you produce 2 MW output resource/MW capacity usage. + This implies that a higher value results in more production based on a given capacity. + It is hence important to be careful when choosing values to avoid a perpetual motion machine. - **`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 "Constructor for `NewNetworkNode`" + !!! note "Constructor for `FlexibleOutput`" The field `data` is not required as we include a constructor when the value is excluded. !!! warning "Using `CaptureData`" - If you plan to use [`CaptureData`](@extref EnergyModelsBase.CaptureData) for a [`NewNetworkNode`] node, it is crucial that you specify your COβ‚‚ resource in the `output` dictionary. + If you plan to use [`CaptureData`](@extref EnergyModelsBase.CaptureData) for a [`FlexibleOutput`](@ref) node, it is crucial that you specify your COβ‚‚ resource in the `output` dictionary. The chosen value is however **not** important as the COβ‚‚ flow is automatically calculated based on the process utilization and the provided process emission value. The reason for this necessity is that flow variables are declared through the keys of the `output` dictionary. Hence, not specifying COβ‚‚ as `output` resource results in not creating the corresponding flow variable and subsequent problems in the design. @@ -75,7 +82,7 @@ The [`FlexibleOutput`](@ref) node uses the standard `NetworkNode` optimization v ### [Constraints](@id nodes-FlexibleOutput-math-con) The following sections omit the direct inclusion of the vector of flexible output nodes. -Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ N^{FlexibleOutput}`` for all [`FlexibleOutput`](@ref) types if not stated differently. +Instead, it is implicitly assumed that the constraints are valid ``\forall n ∈ ^{FlexibleOutput}`` for all [`FlexibleOutput`](@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 strategic periods). #### [Standard constraints](@id nodes-FlexibleOutput-math-con-stand) diff --git a/test/network/test_FlexibleOutput.jl b/test/network/test_FlexibleOutput.jl index 1f3ed0c..80618d0 100644 --- a/test/network/test_FlexibleOutput.jl +++ b/test/network/test_FlexibleOutput.jl @@ -30,8 +30,8 @@ function flexible_factory_case(; cap=FixedProfile(10)) # Interpretation: # - Factory has cap = 10 # - It can produce either prod1 or prod2 (or both) - # - prod2 is "more intensive": needs twice as much capacity per unit - # + # - prod2 is "less intensive": needs half as much capacity per unit (2 units produced + # per capacity usage) # Constraint enforced: # prod1/1 + prod2/2 = cap_use factory = FlexibleOutput(