Skip to content

Commit 586386b

Browse files
authored
Fix problems with TwoLevelTree (#94)
* Minor simplification in checks * Fixed problem with `scale_op_sp` and `TwoLevelTree`
1 parent 54fc0d4 commit 586386b

8 files changed

Lines changed: 139 additions & 99 deletions

File tree

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Release notes
22

3+
## Version 0.10.4 (2026-06-15)
4+
5+
### Bug fixes
6+
7+
* Fixed a bug regarding the branch probability when using a `TwoLevelTree` structure:
8+
* This bug resulted in strategic variables being scaled when calculated from operational variables through the function `scale_op_sp`.
9+
* As a consequence, as an example, emission limits where wrongly applied.
10+
311
## Version 0.10.3 (2026-06-10)
412

513
### Bug fixes

Project.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name = "EnergyModelsBase"
22
uuid = "5d7e687e-f956-46f3-9045-6f5a5fd49f50"
33
authors = ["Lars Hellemo <Lars.Hellemo@sintef.no>, Julian Straus <Julian.Straus@sintef.no>"]
4-
version = "0.10.3"
4+
version = "0.10.4"
55

66
[deps]
77
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
@@ -18,5 +18,5 @@ EMIExt = "EnergyModelsInvestments"
1818
EnergyModelsInvestments = "0.9"
1919
JuMP = "1"
2020
SparseVariables = "0.7.3"
21-
TimeStruct = "0.9"
21+
TimeStruct = "0.9.11"
2222
julia = "1.10"

docs/src/manual/optimization-variables.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The latter is the recommended approach.
99
!!! note
1010
The majority of the variables in `EnergyModelsBase` are rate variables.
1111
This imples that they are calculated for either an operational period duration of 1, when indexed over operational period ``t`` or a strategic period duration of 1, when indexed over strategic period ``t_\texttt{inv}``.
12-
Typical units for rates are MW for energy streams, tonne/hour for mass streams, tonne/year for strategic emissions, and €/year for operational expenditures.
12+
Typical units for rates are MW for energy streams, tonne/hour for mass streams, tonne/year for strategic emissions, and €/year for operating expenses.
1313
In this example, the duration of an operational period of 1 corresponds to an hour, while the duration of a strategic period of 1 corresponds to a year.
1414

1515
Variables that are energy/mass based have that property highlighted in the documentation below.
@@ -27,9 +27,16 @@ The multiplication then leads to an energy/mass quantity in stead of an energy/m
2727
The coupling of strategic and operational periods can be achieved through the function `scale_op_sp(t, t_inv)`.
2828
This functions allows for considering the scaling of the operational periods within a strategic period.
2929

30+
!!! note "`TwoLevelTree` and variables"
31+
All variables that are indexed over strategic periods do not take into consideration the branch probability.
32+
This is, *e.g.*, the case for the strategic emission variables or the variable operating expenses that apply per scenario/branch.
33+
The reason for this approach is to simplify the comparison of individual values between different branches without the need to consider the probability.
34+
35+
The branch probability is however taken into account when calculating the objective function.
36+
3037
## [Operational cost variables](@id man-opt_var-opex)
3138

32-
Operational cost variables are included to account for operational expenditures (OPEX) of the model.
39+
Operational cost variables are included to account for operating expenses (OPEX) of the model.
3340
These costs are pure dependent on either the use or the installed capacity of a node ``n``.
3441
All nodes ``n`` (except [`Availability`](@ref)-nodes) have the following variables representing the operational costs of the nodes:
3542

src/checks.jl

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -710,12 +710,12 @@ function check_representative_profile(time_profile::TimeProfile, message::String
710710
# Iterate through the strategic profiles, if existing
711711
if isa(time_profile, StrategicProfile)
712712
for l1_profile time_profile.vals
713-
sub_msg = "in strategic profiles " * message
714-
bool_rp = check_repr_sub_profile(l1_profile, sub_msg, bool_rp)
713+
sub_msg_1 = "in strategic profiles " * message
714+
bool_rp = check_repr_sub_profile(l1_profile, sub_msg_1, bool_rp)
715715
if isa(l1_profile, RepresentativeProfile)
716716
for l2_profile l1_profile.vals
717-
sub_msg = "in representative profiles in strategic profiles " * message
718-
bool_rp = check_repr_sub_profile(l2_profile, sub_msg, bool_rp)
717+
sub_msg_2 = "in representative profiles " * sub_msg_1
718+
bool_rp = check_repr_sub_profile(l2_profile, sub_msg_2, bool_rp)
719719
end
720720
end
721721
end
@@ -748,8 +748,8 @@ Function for checking that an individual `TimeProfile` does not include the wron
748748
scenario indexing.
749749
750750
## Checks
751-
- `TimeProfile`s accessed in `RepresentativePeriod`s cannot include `OperationalProfile`
752-
or `ScenarioProfile` as this is not allowed through indexing on the `TimeProfile`.
751+
- `TimeProfile`s accessed in `OperationalScenario`s cannot include `OperationalProfile` as
752+
this is not allowed through indexing on the `TimeProfile`.
753753
"""
754754
function check_scenario_profile(time_profile::TimeProfile, message::String)
755755
# Check on the highest level
@@ -758,23 +758,23 @@ function check_scenario_profile(time_profile::TimeProfile, message::String)
758758
# Iterate through the strategic profiles, if existing
759759
if isa(time_profile, StrategicProfile)
760760
for l1_profile time_profile.vals
761-
sub_msg = "in strategic profiles " * message
762-
bool_scp = check_osc_sub_profile(l1_profile, sub_msg, bool_scp)
761+
sub_msg_1 = "in strategic profiles " * message
762+
bool_scp = check_osc_sub_profile(l1_profile, sub_msg_1, bool_scp)
763763
if isa(l1_profile, RepresentativeProfile)
764+
sub_msg_2 = "in representative profiles " * sub_msg_1
764765
for l2_profile l1_profile.vals
765-
sub_msg = "in representative profiles in strategic profiles " * message
766-
bool_scp = check_osc_sub_profile(l2_profile, sub_msg, bool_scp)
766+
bool_scp = check_osc_sub_profile(l2_profile, sub_msg_2, bool_scp)
767767
if isa(l2_profile, ScenarioProfile)
768+
sub_msg_3 = "in scenario profiles in " * sub_msg_2
768769
for l3_profile l2_profile.vals
769-
sub_msg = "in scenario profiles in representative profiles in strategic profiles " * message
770-
bool_scp = check_osc_sub_profile(l3_profile, sub_msg, bool_scp)
770+
bool_scp = check_osc_sub_profile(l3_profile, sub_msg_3, bool_scp)
771771
end
772772
end
773773
end
774774
elseif isa(l1_profile, ScenarioProfile)
775775
for l2_profile l1_profile.vals
776-
sub_msg = "in scenario profiles in strategic profiles " * message
777-
bool_scp = check_osc_sub_profile(l2_profile, sub_msg, bool_scp)
776+
sub_msg_2 = "in scenario profiles " * sub_msg_1
777+
bool_scp = check_osc_sub_profile(l2_profile, sub_msg_2, bool_scp)
778778
end
779779
end
780780
end
@@ -783,12 +783,12 @@ function check_scenario_profile(time_profile::TimeProfile, message::String)
783783
# Iterate through the representative profiles, if existing
784784
if isa(time_profile, RepresentativeProfile)
785785
for l1_profile time_profile.vals
786-
sub_msg = "in representative profiles " * message
787-
bool_scp = check_osc_sub_profile(l1_profile, sub_msg, bool_scp)
786+
sub_msg_1 = "in representative profiles " * message
787+
bool_scp = check_osc_sub_profile(l1_profile, sub_msg_1, bool_scp)
788788
if isa(l1_profile, ScenarioProfile)
789789
for l2_profile l1_profile.vals
790-
sub_msg = "in scenario profiles in representative profiles " * message
791-
bool_scp = check_osc_sub_profile(l2_profile, sub_msg, bool_scp)
790+
sub_msg_2 = "in scenario profiles " * sub_msg_1
791+
bool_scp = check_osc_sub_profile(l2_profile, sub_msg_2, bool_scp)
792792
end
793793
end
794794
end

src/model.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ function objective(m, 𝒳ᵛᵉᶜ, 𝒫, 𝒯, modeltype::EnergyModel)
762762
# Calculation of the objective function.
763763
@objective(m, Max,
764764
-sum(
765-
sum(𝒳[t_inv] for 𝒳 opex) * duration_strat(t_inv)
765+
sum(𝒳[t_inv] for 𝒳 opex) * duration_strat(t_inv) * probability_branch(t_inv)
766766
for t_inv 𝒯ᴵⁿᵛ)
767767
)
768768
end

src/utils.jl

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,23 @@ end
319319
320320
Provides a simplified function for returning the multiplication
321321
322-
``duration(t) * multiple\\_strat(t\\_inv, t) * probability(t)``
322+
``duration(t) * multiple\\_strat(t\\_inv, t) * probability(t) / probability_branch(t)``
323323
324324
when operational periods are coupled with strategic periods. It is used to scale the value
325325
provided for operational periods to a duration of 1 of a strategic period.
326326
327+
!!! note "`TwoLevelTree` application"
328+
The function does not consider the probability of a branch when using a
329+
[`TwoLevelTree`](@extref TimeStruct.TwoLevelTree) time structure. The reason is that we
330+
do not consider any scaling or discounting for the individual strategic variables to
331+
avoid scaling the corresponding bounds. In addition, it allows a simple comparison
332+
of branches with different probabilities.
333+
334+
The scaling is however included in the function [`objective`](@ref) by utilizing the
335+
function [`probability_branch`](@extref TimeStruct.probability_branch)
336+
or [`objective_weight`](@extref TimeStruct.objective_weight) when considering
337+
investments.
338+
327339
# Example
328340
329341
```julia
@@ -340,7 +352,7 @@ scale_op_sp(t_inv, t)
340352
```
341353
"""
342354
scale_op_sp(t_inv::TS.AbstractStrategicPeriod, t::TS.TimePeriod) =
343-
duration(t) * multiple_strat(t_inv, t) * probability(t)
355+
duration(t) * multiple_strat(t_inv, t) * probability(t) / probability_branch(t)
344356

345357
function multiple(t_inv, t)
346358
@warn(

test/test_general.jl

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
1-
function generate_data()
1+
function generate_data(; 𝒯 = TwoLevel(4, 2, SimpleTimes(4, 2), op_per_strat = 8.0))
22

33
# Define the different resources
44
NG = ResourceEmit("NG", 0.2)
55
Coal = ResourceCarrier("Coal", 0.35)
66
Power = ResourceCarrier("Power", 0.0)
77
CO2 = ResourceEmit("CO2", 1.0)
8-
products = [NG, Coal, Power, CO2]
8+
𝒫 = [NG, Coal, Power, CO2]
99

1010
# Creation of the emission data for the individual nodes.
1111
capture_data = CaptureEnergyEmissions(0.9)
1212
emission_data = EmissionsEnergy()
1313

1414
# Create the individual test nodes, corresponding to a system with an electricity demand/sink,
1515
# coal and nautral gas sources, coal and natural gas (with CCS) power plants and CO2 storage.
16-
nodes = [
17-
GenAvailability(1, products),
18-
RefSource(2, FixedProfile(1e12), FixedProfile(30), FixedProfile(0), Dict(NG => 1)),
19-
RefSource(3, FixedProfile(1e12), FixedProfile(9), FixedProfile(0), Dict(Coal => 1)),
16+
𝒩 = [
17+
GenAvailability(1, 𝒫),
18+
RefSource(2, FixedProfile(100), FixedProfile(30), FixedProfile(0), Dict(NG => 1)),
19+
RefSource(3, FixedProfile(100), FixedProfile(9), FixedProfile(0), Dict(Coal => 1)),
2020
RefNetworkNode(
2121
4,
2222
FixedProfile(25),
@@ -52,34 +52,33 @@ function generate_data()
5252
]
5353

5454
# Connect all nodes with the availability node for the overall energy/mass balance
55-
links = [
56-
Direct(14, nodes[1], nodes[4], Linear())
57-
Direct(15, nodes[1], nodes[5], Linear())
58-
Direct(16, nodes[1], nodes[6], Linear())
59-
Direct(17, nodes[1], nodes[7], Linear())
60-
Direct(21, nodes[2], nodes[1], Linear())
61-
Direct(31, nodes[3], nodes[1], Linear())
62-
Direct(41, nodes[4], nodes[1], Linear())
63-
Direct(51, nodes[5], nodes[1], Linear())
64-
Direct(61, nodes[6], nodes[1], Linear())
55+
= [
56+
Direct(14, 𝒩[1], 𝒩[4], Linear())
57+
Direct(15, 𝒩[1], 𝒩[5], Linear())
58+
Direct(16, 𝒩[1], 𝒩[6], Linear())
59+
Direct(17, 𝒩[1], 𝒩[7], Linear())
60+
Direct(21, 𝒩[2], 𝒩[1], Linear())
61+
Direct(31, 𝒩[3], 𝒩[1], Linear())
62+
Direct(41, 𝒩[4], 𝒩[1], Linear())
63+
Direct(51, 𝒩[5], 𝒩[1], Linear())
64+
Direct(61, 𝒩[6], 𝒩[1], Linear())
6565
]
6666

67-
# Creation of the time structure and global data
68-
T = TwoLevel(4, 2, SimpleTimes(4, 2), op_per_strat = 8)
69-
model = OperationalModel(
67+
# Creation of the modeltype
68+
modeltype = OperationalModel(
7069
Dict(CO2 => StrategicProfile([160, 140, 120, 100]), NG => FixedProfile(1e6)),
7170
Dict(CO2 => FixedProfile(10)),
7271
CO2,
7372
)
7473

7574
# Input data structure
76-
case = Case(T, products, [nodes, links], [[get_nodes, get_links]])
77-
return case, model
75+
case = Case(𝒯, 𝒫, [𝒩, ℒ], [[get_nodes, get_links]])
76+
return case, modeltype
7877
end
7978

8079
@testset "General tests" begin
81-
case, model = generate_data()
82-
m = run_model(case, model, HiGHS.Optimizer)
80+
case, modeltype = generate_data()
81+
m = run_model(case, modeltype, HiGHS.Optimizer)
8382

8483
# Retrieve data from the case structure
8584
𝒫 = get_products(case)
@@ -100,6 +99,8 @@ end
10099

101100
= get_links(case)
102101

102+
objective_TwoLevel = objective_value(m)
103+
103104
# Check for the objective value
104105
# (*2 compared to 0.6.0 due to change in strategic period duration)
105106
# (-10400 = 2*10*(160+140+120+100) compared to 0.8.3 due to inclusion of co2 emissions)
@@ -113,10 +114,10 @@ end
113114
# - constraints_emissions(m, 𝒩, 𝒯, 𝒫, modeltype::EnergyModel)
114115
@test all(
115116
value.(m[:emissions_strategic])[t_inv, CO2] <=
116-
EMB.emission_limit(model, CO2, t_inv) for t_inv 𝒯ᴵⁿᵛ
117+
EMB.emission_limit(modeltype, CO2, t_inv) for t_inv 𝒯ᴵⁿᵛ
117118
)
118119
@test all(
119-
value.(m[:emissions_strategic])[t_inv, NG] <= EMB.emission_limit(model, NG, t_inv)
120+
value.(m[:emissions_strategic])[t_inv, NG] <= EMB.emission_limit(modeltype, NG, t_inv)
120121
for t_inv 𝒯ᴵⁿᵛ
121122
)
122123

@@ -140,7 +141,7 @@ end
140141
value.(m[:opex_var][n, t_inv]) + value.(m[:opex_fixed][n, t_inv])
141142
for n 𝒩ᵒᵖᵉˣ) +
142143
sum(
143-
value.(m[:emissions_total][t, CO2]) * emission_price(model, CO2, t) *
144+
value.(m[:emissions_total][t, CO2]) * emission_price(modeltype, CO2, t) *
144145
scale_op_sp(t_inv, t) for t t_inv)
145146
) * duration_strat(t_inv) for t_inv 𝒯ᴵⁿᵛ
146147
) objective_value(m) atol = TEST_ATOL
@@ -184,4 +185,10 @@ end
184185
p EMB.link_res(l), atol TEST_ATOL
185186
) for l ℒ, atol TEST_ATOL
186187
)
188+
189+
# Test that the results are exactly the same for an equivalent `TwoLevelTree`
190+
𝒯 = TwoLevelTree(2, [2, 2, 2], SimpleTimes(4, 2), op_per_strat = 8.0)
191+
case, modeltype = generate_data(; 𝒯)
192+
m = run_model(case, modeltype, HiGHS.Optimizer)
193+
@test objective_TwoLevel objective_value(m)
187194
end

0 commit comments

Comments
 (0)