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/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/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..73cd771 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("examples", "flexible_demand.jl") +Literate.markdown(inputfile, joinpath("docs", "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/", "EnergyModelsBase" => "https://energymodelsx.github.io/EnergyModelsBase.jl/stable/", @@ -41,11 +47,15 @@ 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", ], "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", @@ -57,9 +67,13 @@ 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", ], + "Links" => Any[ + "CapacityCostLink"=>"links/capacitycostlink.md", + ], "How-to" => Any["Contribute"=>"how-to/contribute.md"], "Library" => Any[ 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/docs/src/library/internals/methods-EMB.md b/docs/src/library/internals/methods-EMB.md index 6837e3a..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,21 +9,33 @@ Pages = ["methods-EMB.md"] ## [Extension methods](@id lib-int-met_emb-ext) ```@docs -EnergyModelsBase.variables_element +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 +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/internals/methods-EMF.md b/docs/src/library/internals/methods-EMF.md index 6f3ca72..680fb9f 100644 --- a/docs/src/library/internals/methods-EMF.md +++ b/docs/src/library/internals/methods-EMF.md @@ -11,4 +11,12 @@ Pages = ["methods-EMF.md"] ```@docs EnergyModelsFlex.check_period_ts EnergyModelsFlex.check_limits_default +EnergyModelsFlex.check_input +``` + +## [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 4b7ef87..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,28 +20,38 @@ 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 +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 ActivationCostNode LimitedFlexibleInput Combustion +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 StorageEfficiency -``` \ No newline at end of file +``` + +## [`Link` types](@id lib-pub-link) + +The following link types are implemented in the `EnergyModelsFlex`: + +```@docs +CapacityCostLink +``` diff --git a/docs/src/links/capacitycostlink.md b/docs/src/links/capacitycostlink.md new file mode 100644 index 0000000..21e7f73 --- /dev/null +++ b/docs/src/links/capacitycostlink.md @@ -0,0 +1,141 @@ +# [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. + +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: + +- **`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. + !!! 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 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`](@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. + !!! 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{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) + +#### [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{ccl\_max\_cap\_use}[l, t_{sub}] +``` + +The capacity cost is calculated as: + +```math +\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. + +Finally, costs are aggregated to each strategic period: + +```math +\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: + +```math +\texttt{flow\_in}[l, t, cap\_resource(l)] \leq \texttt{link\_cap\_inst}[l, t] +``` diff --git a/docs/src/nodes/network/flexibleoutput.md b/docs/src/nodes/network/flexibleoutput.md new file mode 100644 index 0000000..d08ec36 --- /dev/null +++ b/docs/src/nodes/network/flexibleoutput.md @@ -0,0 +1,154 @@ +# [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) + +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. +- **`cap::TimeProfile`**:\ + 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 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. +- **`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. + !!! 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 `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 [`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. + + 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. +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 flexible output nodes. +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) + +[`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₂, 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] +``` + +#### [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 new file mode 100644 index 0000000..7db0dbe --- /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, 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. + +## [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 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: + +```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/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/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, +) 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 diff --git a/src/EnergyModelsFlex.jl b/src/EnergyModelsFlex.jl index c021fb4..b5ed038 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, FlexibleOutput, InflexibleSource end diff --git a/src/link/checks.jl b/src/link/checks.jl new file mode 100644 index 0000000..f2fa055 --- /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..af92fdb --- /dev/null +++ b/src/link/datastructures.jl @@ -0,0 +1,161 @@ +""" + 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 + +""" + EMB.has_capacity(l::CapacityCostLink) + +The [`CapacityCostLink`](@ref) has a capacity, and hence, requires the declaration of capacity +variables. +""" +EMB.has_capacity(l::CapacityCostLink) = true + +""" + EMB.capacity(l::CapacityCostLink) + EMB.capacity(l::CapacityCostLink, 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] + +""" + 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 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] + +""" + cap_price_periods(l::CapacityCostLink) + +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 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 new file mode 100644 index 0000000..53d3bcf --- /dev/null +++ b/src/link/model.jl @@ -0,0 +1,104 @@ +""" + EMB.variables_element(m, ℒˢᵘᵇ::Vector{<:CapacityCostLink}, 𝒯, modeltype::EnergyModel) + +Creates the following additional variable for **ALL** capacity cost links: +- `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`. +- `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_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 +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(𝒯) + p_cap = 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 ∈ 𝒯], + 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, p_cap] ≤ m[:link_cap_inst][l, t]) + constraints_capacity_installed(m, l, 𝒯, modeltype) + + # Max capacity use constraints + @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[: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[:ccl_cap_cost][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) * duration(t) for t ∈ t_sub) / sum(duration(t) for t ∈ t_sub) +end + +""" + create_sub_periods(l::CapacityCostLink, 𝒯) + +Extract sub periods of 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/src/network/checks.jl b/src/network/checks.jl index d6ddd40..80a43a8 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,32 @@ 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, +) + @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 + """ check_limits_default(n::Union{LimitedFlexibleInput, Combustion}) @@ -98,3 +126,15 @@ 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 diff --git a/src/network/constraint_functions.jl b/src/network/constraint_functions.jl index 368abf0..773e6da 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..ff397ba 100644 --- a/src/network/datastructures.jl +++ b/src/network/datastructures.jl @@ -247,6 +247,67 @@ 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 + opex_var::TimeProfile + opex_fixed::TimeProfile + input::Dict{<:Resource,<:Real} + output::Dict{<:Resource,<:Real} + data::Vector{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/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/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..14323ad --- /dev/null +++ b/test/link/test_CapacityCostLink.jl @@ -0,0 +1,254 @@ +# 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, co2] + + # Creation of the time structure + op_number = 24 + 𝒯 = TwoLevel([1, 2, 10], SimpleTimes(op_number, 1); op_per_strat = 8760) + + # 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(𝒯, 𝒫, [𝒩, ℒ]) + modeltype = OperationalModel( + Dict(co2 => FixedProfile(10)), + Dict(co2 => FixedProfile(0)), + co2, + ) + m = create_model(case, modeltype) + + return m, case, modeltype +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 + + # Test that capacity is non-negative + @test_throws AssertionError capacity_cost_link_case(; cap = FixedProfile(-5)) + + # Test that capacity price is non-negative + capacity_price = StrategicProfile([-1e5, 1e6, 2e6]) + @test_throws AssertionError capacity_cost_link_case(; capacity_price) + + # 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) + + # 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(cc_link) + @test has_opex(cc_link) + + # Test the extraction functions + @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(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 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", + 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 "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][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][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] ≤ ccl_max_cap_use[t_sub_end] + @test all( + all( + 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 ∈ 𝒯ˢᵘᵇ + ) + + # Capacity cost at end of sub-period: cap_cost == max_cap_use * avg_cap_price + @test all( + 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(ccl_cap_cost over t_inv) + @test all( + 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 ∈ 𝒯ᴵⁿᵛ + ) + + # 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][cc_link, t, power]) ≈ 1.0 for t ∈ 𝒯ˢᵘᵇ[1]) + + # Check that the opex is correct + @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 `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) + @test value.(m[:opex_var][src_exp, 𝒯ᴵⁿᵛ[3]]) ≈ (10 + 9 + (24-2)*1) * 400 * (8760/24) +end 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_FlexibleOutput.jl b/test/network/test_FlexibleOutput.jl new file mode 100644 index 0000000..80618d0 --- /dev/null +++ b/test/network/test_FlexibleOutput.jl @@ -0,0 +1,166 @@ +# 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_case(; cap=FixedProfile(10)) + # 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 "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( + "factory", + cap, + FixedProfile(0.2), + FixedProfile(0.1), + 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 + modeltype = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => StrategicProfile([0, 2e4, 1e5])), + CO2, + ) + + case = Case(T, resources, [nodes, links], [[get_nodes, get_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 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 + +@testset "Constraint implementation" begin + # Create the case and modeltype + m, case, modeltype = flexible_factory_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) + 𝒩 = get_nodes(case) + factory = 𝒩[3] + sink_prod_1 = 𝒩[4] + sink_prod_2 = 𝒩[5] + + # 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/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 3d2bc07..95f9bcf 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 @@ -35,7 +36,13 @@ include("utils.jl") include("Aqua.jl") # Check if there is need for formatting - include("JuliaFormatter.jl") + # include("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 ∈ @@ -51,7 +58,7 @@ include("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 @@ -62,6 +69,7 @@ include("utils.jl") "MinUpDownTimeNode", "Combustion", "ActivationCostNode", + "FlexibleOutput", ] run_node_test("network", node_type) 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..fc4ac1e --- /dev/null +++ b/test/source/test_InflexibleSource.jl @@ -0,0 +1,110 @@ +# Resources used in the analysis +power = ResourceCarrier("Power", 0.0) +co2 = ResourceEmit("CO₂", 1.0) + +# Function for setting up the system +function inflexible_source_case(; output=Dict(power => 1)) + + source = InflexibleSource( + "source", + StrategicProfile([ + OperationalProfile([8, 5, 7, 11, 6]), + OperationalProfile([6, 3, 5, 9, 5]), + ]), + FixedProfile(2), + FixedProfile(10), + output, + ) + sink = RefSink( + "sink", + OperationalProfile([6, 8, 10, 6, 8]), + Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(10)), + 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)) + + # 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) + + # 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][src, t]) ≈ value.(m[:cap_inst][src, 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][snk, t]) ≈ deficit[t] for t ∈ 𝒯, atol ∈ TEST_ATOL + ) + @test all( + value.(m[:sink_surplus][snk, 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 b49eec9..bd28a9a 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -1,9 +1,41 @@ 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 +""" + 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)