diff --git a/NEWS.md b/NEWS.md index d102368..7ea8243 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,16 @@ # Release Notes +## Version 0.8.4 (2026-08-24) + +### Bug fixes + +* Fixed a bug in the tests of the checks of an `AbstractElectrolyzer`. +* Fixed a bug in the lifetime bounds of an `AbstractElectrolyzer`. + * The bug occured when + 1. the sum of the durations of the operational periods were not equal to the parameter `op_per_strat` and + 2. the stack lifetime was shorter than the parameter `op_per_strat`. + * Solving the bug resulted in changing the constraint for the upper bound of the lifetime with redundant strategic constraints when the stack lifetime is longer than the paramerer `op_per_strat` times the duration of a strategic period. + ## Version 0.8.3 (2025-08-17) ### Bugfix diff --git a/Project.toml b/Project.toml index 65de293..9c0237f 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "EnergyModelsHydrogen" uuid = "44855f8b-b147-4985-ac18-48817d03c548" authors = ["Julian Straus , Avinash Subramanian"] -version = "0.8.3" +version = "0.8.4" [deps] EnergyModelsBase = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" diff --git a/docs/src/nodes/electrolyzer.md b/docs/src/nodes/electrolyzer.md index 0e55274..c29490d 100644 --- a/docs/src/nodes/electrolyzer.md +++ b/docs/src/nodes/electrolyzer.md @@ -8,6 +8,22 @@ Hence, incorporating the potential for stack replacement and the associated cost Stack replacement is cheaper than rebuilding a complete plant. Furthermore, it results in an improved efficiency as it resets the degradation. +!!! note "Degradation and stack utilization calculations" + The degradation and stack utilization calculations do not include the multiplier (to scale between operational and strategic periods) within a given `SimpleTimes` structure or the duration of a strategic period. + We only consider the duration of the operational period. + + As an example, consider the following time structure: + + ```julia + 𝒯 = TwoLevel(1, 2, SimpleTimes(24, 1); 8760.0) + ``` + + which corresponds to modelling a strategic period with a duration of 2 years and 24 operational periods with a duration of 1 h each. + In this situation, the maximum achievable degradation and stack utilization is given by the degradation after 24 hours of usage while theoretically it would be possible to have in the second year of the strategic period a total previous usage of 17 496 hours. + This is an optimistic treatment of the behavior while maintaining the degradation change within the given `SimpeTimes` structure. + + However, when using representative periods, we do account for the multiplier for `SimpleTimes` structures as the core assumption is that the individual time structure within a representative period is repeated. + ## [Introduced types and their fields](@id nodes-elec-fields) Electrolysis is incorporated through two composite types with the same parameters. @@ -71,7 +87,7 @@ The standard fields are given as: The lower limit has to be non-negative while the upper limit has to be higher than the lower limit. - **`degradation_rate::Real`**:\ The degradation rate is the reduction in efficiency of the electrolyser due to utilization. - It has to be provided as a percentage drop in efficiency in 1000 time the length of an operational duration (see *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS-struct-sp)* for an explanation). + It has to be provided as a percentage drop in efficiency in 1000 times the length of an operational duration (see *[Utilize `TimeStruct`](@extref EnergyModelsBase how_to-utilize_TS-struct-sp)* for an explanation). If a duration of 1 in an operational period corresponds to an hour, then the unit is %/1000h.\ The degradation rate has to be given as ``[0, 1)``. - **`stack_replacement_cost::TimeProfile`**:\ @@ -299,8 +315,6 @@ The calculation of the previous usage of the electrolyzer node requires the defi The overall approach is similar to the calculation of the level constraints in `EnergyModelsBase`. This is achieved through the function `constraints_usage()` and the individual functions calculated from the function. -Within this function, we first calculate ``\forall t_{inv, 1} \in T^{Inv},~ t_{inv, 2} \in T^{Inv}`` the linear reformulation of the product - First, the usage in each investment period ``t_{inv}`` is calculated: ```math @@ -308,6 +322,15 @@ First, the usage in each investment period ``t_{inv}`` is calculated: \times scale\_op\_sp(t_{inv}, t) ``` +In addition, we add the constraint on the stack lifetime. + +```math +\begin{aligned} +stack\_&lifetime(n) / 1000 \geq \\ & +\texttt{elect\_prev\_use\_sp}[n_{el}, t]_{inv} + \texttt{elect\_use\_sp}[n_{el}, t_{inv}] \times duration\_strat(t_{inv}) +\end{aligned} +``` + The previous usage up the current investment period ``t_{inv}`` is calculated through the function `constraints_usage_sp`. In the first investment period, the previous usage is fixed to a value of 0: @@ -362,20 +385,6 @@ If the `TimeStructure` includes representative periods, then the usage in each r \times scale\_op\_sp(t_{inv}, t) ``` -In addition, if we are in the last operational period (of the last representative period) of an investment period, we calculate (for each operational scenario) the constraint - -```math -\begin{aligned} -stack\_&lifetime(n) \geq \\ & -\texttt{elect\_prev\_use}[n_{el}, t] \times 1000 + \\ & -\texttt{elect\_use\_sp}[n_{el}, t_{inv}] \times (duration\_strat(t_{inv}) - 1) \times 1000 + \\ -& \texttt{elect\_on\_b}[n_{el}, t] \times scale\_op\_sp(t_{inv}, t) -\end{aligned} -``` - -to avoid a violation of the lifetime constraint. -This constraint is only necessary for the last operational period as stack replacement is only allowed at the beginning of an investment period. - The declaration of the actual constraint for the previous usage can be differentiated in four individual cases: 1. In the first operational period (in the first representative period) in the first investment period:\ diff --git a/src/constraints/electrolyzer.jl b/src/constraints/electrolyzer.jl index b3a04c1..f10f8c6 100644 --- a/src/constraints/electrolyzer.jl +++ b/src/constraints/electrolyzer.jl @@ -8,12 +8,20 @@ degradation calculations. function constraints_usage(m, n::AbstractElectrolyzer, 𝒯ᴵⁿᵛ, modeltype::EnergyModel) # Mass/energy balance constraints for stored energy carrier. for (t_inv_prev, t_inv) ∈ withprev(𝒯ᴵⁿᵛ) - # Calculation of hte usage within a strategic period + # Calculation of the usage within a strategic period @constraint(m, m[:elect_use_sp][n, t_inv] * 1000 == sum(m[:elect_on_b][n, t] * scale_op_sp(t_inv, t) for t ∈ t_inv) ) + # Upper constraint for the total usage of the electrolyzer + @constraint(m, + stack_lifetime(n)/1000 ≥ + m[:elect_prev_use_sp][n, t_inv] + + m[:elect_use_sp][n, t_inv] * duration_strat(t_inv) + ) + + # Initialization of the periods prev_pers = PreviousPeriods(t_inv_prev, nothing, nothing); elec_pers = ElecPeriods(𝒯ᴵⁿᵛ, t_inv, nothing, true) @@ -165,25 +173,6 @@ function constraints_usage_iterate( _::SimpleTimes, modeltype::EnergyModel, ) - # Constraint for the total usage of the electrolyzer including the current time step. - # This ensures that the last repetition of the strategic period is appropriately - # constrained. - # The conditional statement activates this constraint only for the last representative - # period, if representative periods are present as stack replacement is only feasible - # once per strategic period - if is_last(elec_pers) - t_inv = strat_per(elec_pers) - t = last(per) - @constraint(m, - stack_lifetime(n) ≥ - ( - m[:elect_prev_use][n, t] + - m[:elect_use_sp][n, t_inv]*(duration_strat(t_inv) - 1) - ) - * 1000 + m[:elect_on_b][n, t] * scale_op_sp(t_inv, t) - ) - end - # Iterate through the operational structure for (t_prev, t) ∈ withprev(per) prev_pers = PreviousPeriods(EMB.strat_per(prev_pers), EMB.rep_per(prev_pers), t_prev); diff --git a/src/model.jl b/src/model.jl index f47d293..bc33aaa 100644 --- a/src/model.jl +++ b/src/model.jl @@ -110,7 +110,6 @@ function EMB.create_node(m, n::AbstractElectrolyzer, 𝒯, 𝒫, modeltype::Ener + stack_replace[t_inv] * stack_replacement_cost(n, t_inv) / duration_strat(t_inv) ) - # Call of the functions for the variable OPEX constraint introduction constraints_opex_var(m, n, 𝒯ᴵⁿᵛ, modeltype) end diff --git a/test/test_checks.jl b/test/test_checks.jl index 7e77f5b..9d12023 100644 --- a/test/test_checks.jl +++ b/test/test_checks.jl @@ -9,7 +9,7 @@ CO2 = ResourceEmit("CO2", 1.0) # Function for setting up the system for testing an `AbstractElectrolyzer` node function simple_graph_elec(; - cap = FixedProfile(-25), # Installed capacity [MW] + cap = FixedProfile(25), # Installed capacity [MW] opex_var = FixedProfile(5), # Variable Opex opex_fixed = FixedProfile(100), # Fixed Opex input = Dict(Power => 1), # Input: Ratio of Input flows to characteristic throughput @@ -76,7 +76,7 @@ end @test_throws AssertionError simple_graph_elec(cap=FixedProfile(-25)) # Test that a wrong fixed OPEX is caught by the checks - @test_throws AssertionError simple_graph_elec(;opex_var=FixedProfile(5)) + @test_throws AssertionError simple_graph_elec(;opex_fixed=FixedProfile(-5)) # Test that a wrong input dictionary is caught by the checks @test_throws AssertionError simple_graph_elec(;input=Dict(Power => -1)) diff --git a/test/test_electrolyzer.jl b/test/test_electrolyzer.jl index 929ad48..9d4f1fe 100644 --- a/test/test_electrolyzer.jl +++ b/test/test_electrolyzer.jl @@ -97,20 +97,20 @@ function penalty_test(m, case) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) # Reassign variables - penalty = m[:elect_efficiency_penalty][elec, :] - stack_replace = m[:elect_stack_replace_b][elec, :] + penalty = value.(m[:elect_efficiency_penalty][elec, :]) + stack_replace = value.(m[:elect_stack_replace_b][elec, :]) + prev_use = value.(m[:elect_prev_use][elec, :]) # Calculation of the penalty @test all( - value.(penalty[t]) ⪅ value.(penalty[t_prev]) + penalty[t] ⪅ penalty[t_prev] for (t_prev, t) ∈ withprev(𝒯) if !isnothing(t_prev) ) + @test all(prev_use[t] ⪅ EMH.stack_lifetime(elec) for t ∈ 𝒯) @test all( - value.(m[:elect_prev_use][elec, t]) ⪅ EMH.stack_lifetime(elec) for t ∈ 𝒯 - ) - @test all( - value.(penalty[t]) ≈ - 1 - EMH.degradation_rate(elec)/100 * value.(m[:elect_prev_use][elec, t]) + isapprox( + penalty[t], 1 - EMH.degradation_rate(elec)/100 * prev_use[t], atol=1e-6 + ) for t ∈ 𝒯) # Test that the previous usage is correctly calculated @@ -119,9 +119,16 @@ function penalty_test(m, case) ( value.(m[:elect_prev_use_sp][elec, t_inv_prev]) + value.(m[:elect_use_sp][elec, t_inv_prev]) * 2 - ) * (1 - value.(stack_replace[t_inv])), + ) * (1 - stack_replace[t_inv]), atol = TEST_ATOL) for (t_inv_prev, t_inv) ∈ withprev(𝒯ᴵⁿᵛ) if !isnothing(t_inv_prev)) + + # Test that the stack lifetime constraint is not violated + @test all( + EMH.stack_lifetime(elec)/1000 ⪆ + value.(m[:elect_prev_use_sp][elec, t_inv]) * (1-stack_replace[t_inv]) + + value.(m[:elect_use_sp][elec, t_inv]) * duration_strat(t_inv) + for t_inv ∈ 𝒯ᴵⁿᵛ) end # Testset for the individual extraction methods incorporated in the model @@ -201,6 +208,12 @@ end # Test that there are no quadratic constraints for SimpleElectrolyzer types @test isempty(all_constraints(m, QuadExpr, MOI.EqualTo{MOI.Float64})) finalize(backend(m).optimizer.model) + + # Test for stack lifetimes shorter than op_per_strat + 𝒯 = TwoLevel(8, 2, SimpleTimes(20, 1); op_per_strat=8760) + stack_lifetime = 8000 + # Run and test the model + m, case, modeltype = elec_test_case(𝒯; stack_cost, deficit_cost, stack_lifetime) end # Test set for the used load limits allowing for both production above and below capacity @@ -241,7 +254,7 @@ end @testset "Investment extension test" begin @testset "Without investment data" begin # Specifying the input parameters - 𝒯 = TwoLevel(8, 2, SimpleTimes(20, 8760/20); op_per_strat=8760) + 𝒯 = TwoLevel(8, 2, SimpleTimes(20, 1); op_per_strat=8760) deficit_cost = StrategicProfile([25, 25, 25, 25, 30]) stack_cost = FixedProfile(3e8)