Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release notes

## Version 0.9.5 (2025-03-23)

* New functions (`variables_flow_resource()`, `constraints_resource()`, `constraints_couple_resource()`) that dispatch on resource types, which allow for creation of new resource-specific variables and constraints in extension packages.
* New function to indentify the unique resource types of a vector of resources
* New function that segments the vector of resources into sub-vectors based on each resource type

## Version 0.9.4 (2025-11-26)

### Bugfixes
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "EnergyModelsBase"
uuid = "5d7e687e-f956-46f3-9045-6f5a5fd49f50"
authors = ["Lars Hellemo <Lars.Hellemo@sintef.no>, Julian Straus <Julian.Straus@sintef.no>"]
version = "0.9.4"
version = "0.9.5"

[deps]
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
Expand Down
4 changes: 2 additions & 2 deletions docs/src/how-to/utilize-timestruct.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ op_number = length(op_duration)
operational_periods = SimpleTimes(op_number, op_duration)

# output
SimpleTimes{Int64}(11, [4, 2, 1, 1, 2, 4, 2, 1, 1, 2, 4])
SimpleTimes{Int64}(11, [4, 2, 1, 1, 2, 4, 2, 1, 1, 2, 4], 24)
```

In this case, we model the day not with hourly resolution, but only have hourly resolution in the morning and afternoon.
Expand All @@ -60,7 +60,7 @@ Instead, one can also write
operational_periods = SimpleTimes(op_duration)

# output
SimpleTimes{Int64}(11, [4, 2, 1, 1, 2, 4, 2, 1, 1, 2, 4])
SimpleTimes{Int64}(11, [4, 2, 1, 1, 2, 4, 2, 1, 1, 2, 4], 24)
```

and a constructor will automatically deduce that there have to be 11 operational periods.
Expand Down
5 changes: 5 additions & 0 deletions docs/src/library/internals/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ emissions_operational
constraints_emissions
constraints_elements
constraints_couple
constraints_couple_resource
constraints_resource
constraints_level_iterate
constraints_level_rp
constraints_level_scp
Expand All @@ -39,6 +41,7 @@ constraints_level_bounds
```@docs
variables_capacity
variables_flow
variables_flow_resource
variables_opex
variables_capex
variables_emission
Expand Down Expand Up @@ -96,4 +99,6 @@ res_sub
```@docs
collect_types
sort_types
res_types
res_types_seg
```
90 changes: 83 additions & 7 deletions src/model.jl
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function create_model(
# Declaration of element variables and constraints of the problem
for 𝒳 ∈ 𝒳ᵛᵉᶜ
variables_capacity(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒯, modeltype)
variables_flow(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒯, modeltype)
variables_flow(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change is a breaking change.

I am not opposed to it as I do not really see another approach, but I think it is beneficial to think whether we want to include additional breaking changes.

The breaking change could be circumvented by adding the following default method:

variables_flow(m, 𝒳::Vector{<:AbstractElement}, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype::EnergyModel) =
    variables_flow(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒯, modeltype)

I tested it with EnergyModelsGeography and it worked.

variables_opex(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒯, modeltype)
variables_capex(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒯, modeltype)
variables_emission(m, 𝒳, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype)
Expand Down Expand Up @@ -230,7 +230,7 @@ By default, all nodes `𝒩` and links `ℒ` only allow for unidirectional flow.
bidirectional flow through providing a method to the function [`is_unidirectional`](@ref)
for new link/node types.
"""
function variables_flow(m, 𝒩::Vector{<:Node}, 𝒳ᵛᵉᶜ, 𝒯, modeltype::EnergyModel)
function variables_flow(m, 𝒩::Vector{<:Node}, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype::EnergyModel)
# Extract the nodes with inputs and outputs
𝒩ⁱⁿ = filter(has_input, 𝒩)
𝒩ᵒᵘᵗ = filter(has_output, 𝒩)
Expand All @@ -249,8 +249,14 @@ function variables_flow(m, 𝒩::Vector{<:Node}, 𝒳ᵛᵉᶜ, 𝒯, modeltype:
for n_out ∈ 𝒩ᵒᵘᵗ⁻ᵘⁿⁱ, t ∈ 𝒯, p ∈ outputs(n_out)
set_lower_bound(m[:flow_out][n_out, t, p], 0)
end

# Create new flow variables for specific resource types
for p_sub in res_types_seg(𝒫)
variables_flow_resource(m, 𝒩, p_sub, 𝒯, modeltype)
end

end
function variables_flow(m, ℒ::Vector{<:Link}, 𝒳ᵛᵉᶜ, 𝒯, modeltype::EnergyModel)
function variables_flow(m, ℒ::Vector{<:Link}, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype::EnergyModel)
# Create the link flow variables
@variable(m, link_in[l ∈ ℒ, 𝒯, inputs(l)])
@variable(m, link_out[l ∈ ℒ, 𝒯, outputs(l)])
Expand All @@ -266,8 +272,29 @@ function variables_flow(m, ℒ::Vector{<:Link}, 𝒳ᵛᵉᶜ, 𝒯, modeltype::
set_lower_bound(m[:link_out][l, t, p], 0)
end
end

# Create new flow variables for specific resource types
for p_sub in res_types_seg(𝒫)
variables_flow_resource(m, ℒ, p_sub, 𝒯, modeltype)
end
end

"""
variables_flow_resource(m, ℒ::Vector{<:Link}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel)
variables_flow_resource(m, 𝒩::Vector{<:Node}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel)

Create resource-specific flow variables for links or nodes.

This function is called from [`variables_flow`](@ref) for each subset of resources
sharing the same type. It can be used to add variables and bounds for specialized
resource classes while keeping the default flow variables unchanged.

The default methods are empty and intended to be implemented in extension packages.
"""
function variables_flow_resource(m, ℒ::Vector{<:Link}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel) end
function variables_flow_resource(m, 𝒩::Vector{<:Node}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel) end


"""
variables_opex(m, 𝒩::Vector{<:Node}, 𝒳ᵛᵉᶜ, 𝒯, modeltype::EnergyModel)
variables_opex(m, ℒ::Vector{<:Link}, 𝒳ᵛᵉᶜ, 𝒯, modeltype::EnergyModel)
Expand Down Expand Up @@ -572,11 +599,41 @@ differentiation in extension packages.
- `Node` - the subfunction is [`create_node`](@ref).
- `Link` - the subfunction is [`create_link`](@ref).
"""
create_element(m, n::Node, 𝒯, 𝒫, modeltype::EnergyModel) =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring should be updated for this function as we now actually have specific approaches. This also implies that we want to avoid people to create new methods for create_element as the function constraints_resource is now called within create_element.

This would also be stressed in the documentation.

function create_element(m, n::Node, 𝒯, 𝒫, modeltype::EnergyModel)

create_node(m, n, 𝒯, 𝒫, modeltype)
create_element(m, l::Link, 𝒯, 𝒫, modeltype::EnergyModel) =

# Constraints based on the resource types
node_resources = Vector{Resource}(unique(vcat(inputs(n), outputs(n))))
for 𝒫ˢᵘᵇ in res_types_seg(node_resources)
constraints_resource(m, n, 𝒯, 𝒫ˢᵘᵇ, modeltype)
end
end

function create_element(m, l::Link, 𝒯, 𝒫, modeltype::EnergyModel)

create_link(m, l, 𝒯, 𝒫, modeltype)

# Constraints based on the resource types
for 𝒫ˢᵘᵇ in res_types_seg(link_res(l))
constraints_resource(m, l, 𝒯, 𝒫ˢᵘᵇ, modeltype)
end
end

"""
constraints_resource(m, n::Node, 𝒯, 𝒫::Vector{<:Resource}, modeltype::EnergyModel)
constraints_resource(m, l::Link, 𝒯, 𝒫::Vector{<:Resource}, modeltype::EnergyModel)

Create constraints for the flow of resources through an [`AbstractElement`](@ref) for
specific resource types. In `EnergyModelsBase`, this method is provided for
[`Node`](@ref EnergyModelsBase.Node) and [`Link`](@ref).

The function is empty by default and can be implemented in extension packages.
"""
function constraints_resource(m, n::Node, 𝒯, 𝒫::Vector{<:Resource}, modeltype::EnergyModel) end

function constraints_resource(m, l::Link, 𝒯, 𝒫::Vector{<:Resource}, modeltype::EnergyModel) end

"""
constraints_couple(m, 𝒩::Vector{<:Node}, ℒ::Vector{<:Link}, 𝒫, 𝒯, modeltype::EnergyModel)
constraints_couple(m, ℒ::Vector{<:Link}, 𝒩::Vector{<:Node}, 𝒫, 𝒯, modeltype::EnergyModel)
Expand All @@ -590,13 +647,15 @@ for the coupling between a [`Link`](@ref) and a [`Node`](@ref EnergyModelsBase.N
function constraints_couple(m, 𝒩::Vector{<:Node}, ℒ::Vector{<:Link}, 𝒫, 𝒯, modeltype::EnergyModel)
for n ∈ 𝒩
ℒᶠʳᵒᵐ, ℒᵗᵒ = link_sub(ℒ, n)

# Constraint for output flowrate and input links.
if has_output(n)
@constraint(m, [t ∈ 𝒯, p ∈ outputs(n)],
m[:flow_out][n, t, p] ==
sum(m[:link_in][l, t, p] for l ∈ ℒᶠʳᵒᵐ if p ∈ inputs(l))
)
end

# Constraint for input flowrate and output links.
if has_input(n)
@constraint(m, [t ∈ 𝒯, p ∈ inputs(n)],
Expand All @@ -605,11 +664,29 @@ function constraints_couple(m, 𝒩::Vector{<:Node}, ℒ::Vector{<:Link}, 𝒫,
)
end
end

# Create new constraints for specific resource types
for p_sub in res_types_seg(𝒫)
constraints_couple_resource(m, 𝒩, ℒ, p_sub, 𝒯, modeltype)
end
end
function constraints_couple(m, ℒ::Vector{<:Link}, 𝒩::Vector{<:Node}, 𝒫, 𝒯, modeltype::EnergyModel)
return constraints_couple(m, 𝒩, ℒ, 𝒫, 𝒯, modeltype)
end

"""
constraints_couple_resource(m, 𝒩::Vector{<:Node}, ℒ::Vector{<:Link}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel)

Create resource-specific coupling constraints between nodes and links.

This function is called from [`constraints_couple`](@ref) for each subset of resources
sharing the same type. It can be used to add additional coupling constraints for
specialized resource classes while keeping the default node-link flow balance unchanged.

The default method is empty and intended to be implemented in extension packages.
"""
function constraints_couple_resource(m, 𝒩::Vector{<:Node}, ℒ::Vector{<:Link}, 𝒫::Vector{<:Resource}, 𝒯, modeltype::EnergyModel) end

"""
constraints_emissions(m, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype::EnergyModel)

Expand Down Expand Up @@ -956,7 +1033,6 @@ function create_link(m, l::Direct, 𝒯, 𝒫, modeltype::EnergyModel)
)
end
function create_link(m, 𝒯, 𝒫, l::Link, modeltype::EnergyModel, formulation::Formulation)

# Generic link in which each output corresponds to the input
@constraint(m, [t ∈ 𝒯, p ∈ link_res(l)],
m[:link_out][l, t, p] == m[:link_in][l, t, p]
Expand All @@ -966,4 +1042,4 @@ function create_link(m, 𝒯, 𝒫, l::Link, modeltype::EnergyModel, formulation
if has_capacity(l)
constraints_capacity_installed(m, l, 𝒯, modeltype)
end
end
end
14 changes: 14 additions & 0 deletions src/structures/resource.jl
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,17 @@ Returns all emission resources for a
"""
res_em(𝒫::Array{<:Resource}) = filter(is_resource_emit, 𝒫)
res_em(𝒫::Dict) = filter(p -> is_resource_emit(first(p)), 𝒫)

"""
res_types(𝒫::Vector{<:Resource})

Return the unique resource types in an Vector of resources `𝒫`.
"""
res_types(𝒫::Vector{<:Resource}) = unique(map(x -> typeof(x), 𝒫))

"""
res_types_seg(𝒫::Vector{<:Resource})

Return a Vector-of-Vectors of resources segmented by the sub-types.
"""
res_types_seg(𝒫::Vector{<:Resource}) = [Vector{rt}(filter(x -> isa(x, rt), 𝒫)) for rt in res_types(𝒫)]
Comment on lines +98 to +102
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that always guaranteed, that you receive a Vector{Vector{}}. I experienced some issues when we changed to the new Case structure.

I would suggest renaming the function to res_types_vec.

Copy link
Author

@espenbodal espenbodal Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it does not if the input is empty. Then it returns an empty Vector{Any}, but that is ok. Changed to check that it returns a empty vector for a empty input vector instead

4 changes: 4 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ ENV["EMB_TEST"] = true # Set flag for example scripts to check if they are run a
include("test_data.jl")
end

@testset "Base | Resource" begin
include("test_resource.jl")
end

@testset "Base | Node" begin
include("test_nodes.jl")
end
Expand Down
62 changes: 62 additions & 0 deletions test/test_resource.jl
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do not test that a new Resource is actually implemented properly with all the different functions called. You can take a look how it implemented it for links. The current test set does not really test that the new functionality is working, except for the utility functions. I would however think, it would be beneficial to test properly that the system is working the way it should.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@

Power = ResourceCarrier("Power", 0.0)
Heat = ResourceCarrier("Heat", 0.0)
CO2 = ResourceEmit("CO2", 1.0)

𝒫 = [Power, Heat, CO2]

@testset "Resource - get resource types" begin
# returns a Vector of DataTypes
@test typeof(EMB.res_types(𝒫)) == Vector{DataType}

# returns the correct number of unique resource types
@test length(EMB.res_types(𝒫)) == 2
end

@testset "Resource - get resource vectors by type" begin
# returns a Vector of Vectors
@test typeof(EMB.res_types_seg(𝒫)) == Vector{Vector}

# returns the correct number of segments
@test length(EMB.res_types_seg(𝒫)) == 2

# the length of the first segment should be 2 (2 ResourceCarriers)
@test length(EMB.res_types_seg(𝒫)[1]) == 2

# the length of the second segment should be 1 (1 ResourceEmit)
@test length(EMB.res_types_seg(𝒫)[2]) == 1

end

# Add a new resource type and check that it is correctly identified by res_types and res_types_seg
struct TestResource <: Resource
id::String
a::Float64
b::Int64
end

# Add a new resource of type TestResource to the resource vector
𝒫 = vcat(𝒫, [TestResource("Test", 0.5, 1)])

@testset "Resource - get resource types w/ custom resource type" begin
# returns a Vector of DataTypes (now including TestResource)
@test typeof(EMB.res_types(𝒫)) == Vector{DataType}

# returns the correct number of unique resource types (now 3)
@test length(EMB.res_types(𝒫)) == 3

end

@testset "Resource - get resource vectors by type w/ custom resource type" begin
# returns the correct number of segments (now 3)
@test length(EMB.res_types_seg(𝒫)) == 3

# the length of the first segment should be 2 (2 ResourceCarriers)
@test length(EMB.res_types_seg(𝒫)[1]) == 2

# the length of the second segment should be 1 (1 ResourceEmit)
@test length(EMB.res_types_seg(𝒫)[2]) == 1

# the length of the third segment should be 1 (1 TestResource)
@test length(EMB.res_types_seg(𝒫)[3]) == 1
end
Loading