diff --git a/NEWS.md b/NEWS.md index bb61adf..92982fa 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # Release notes +## Version 0.6.7 (2024-03-21) + +* Allow for deactivation of timeprofile checks while printing a warning in this case. +* Fixed a bug for a too short `StrategicProfile` in the checks. +* Added checks for the case dictionary. +* Extended checks for the modeltype +* Added functions that can be used to check whether a `TimeProfile` can be indexed over `StrategicPeriod`s, `RepresentativePeriod`s, or `OperationalScenario`s. + ## Version 0.6.6 (2024-03-04) ### Examples diff --git a/Project.toml b/Project.toml index 6fd9aa1..b40d1e1 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "EnergyModelsBase" uuid = "5d7e687e-f956-46f3-9045-6f5a5fd49f50" authors = ["Lars Hellemo , Julian Straus "] -version = "0.6.6" +version = "0.6.7" [deps] JuMP = "4076af6c-e467-56ae-b986-b466b2749572" diff --git a/src/checks.jl b/src/checks.jl index 6abcb30..f5cc9f5 100644 --- a/src/checks.jl +++ b/src/checks.jl @@ -33,12 +33,12 @@ end """ - check_data(case, modeltype) + check_data(case, modeltype, check_timeprofiles::Bool) Check if the case data is consistent. Use the `@assert_or_log` macro when testing. Currently only checking node data. """ -function check_data(case, modeltype::EnergyModel) +function check_data(case, modeltype::EnergyModel, check_timeprofiles::Bool) # TODO would it be useful to create an actual type for case, instead of using a Dict with # naming conventions? Could be implemented as a mutable in energymodelsbase.jl maybe? @@ -47,24 +47,47 @@ function check_data(case, modeltype::EnergyModel) global logs = [] log_by_element = Dict() + + if !check_timeprofiles + @warn "Checking of the time profiles is deactivated:\n" * + "Deactivating the checks for the time profiles is strongly discouraged. " * + "While the model will still run, unexpected results can occur, as well as " * + "inconsistent case data.\n\n" * + "Deactivating the checks for the timeprofiles should only be considered, " * + "when testing new components. In all other instances, it is recommended to " * + "provide the correct timeprofiles using a preprocessing routine.\n\n" * + "If timeprofiles are not checked, inconsistencies can occur." maxlog=1 + end + + # Check the case data. If the case data is not in the correct format, the overall check + # is cancelled as extractions would not be possible + check_case_data(case) + log_by_element["Case data"] = logs + if ASSERTS_AS_LOG + compile_logs(case, log_by_element) + end + 𝒯 = case[:T] for n ∈ case[:nodes] # Empty the logs list before each check. global logs = [] - check_node(n, 𝒯, modeltype) + check_node(n, 𝒯, modeltype, check_timeprofiles::Bool) for data ∈ node_data(n) - check_node_data(n, data, 𝒯, modeltype) + check_node_data(n, data, 𝒯, modeltype, check_timeprofiles) + end + + if check_timeprofiles + check_time_structure(n, 𝒯) end - check_time_structure(n, 𝒯) # Put all log messages that emerged during the check, in a dictionary with the node as key. log_by_element[n] = logs end logs = [] - check_model(case, modeltype) - log_by_element[modeltype] = logs + check_model(case, modeltype, check_timeprofiles) + log_by_element["Modeltype"] = logs if ASSERTS_AS_LOG compile_logs(case, log_by_element) @@ -108,16 +131,109 @@ function compile_logs(case, log_by_element) end end +""" + check_case_data(case) + +Checks the `case` dictionary is in the correct format. + +## Checks +- The dictionary requires the keys `:T`, `:nodes`, `:links`, and `:products`. +- The individual keys are of the correct type, that is + - `:T::TimeStructure`, + - `:nodes::Vector{<:Node}`, + - `:links::Vector{<:Link}`, and + - `:products::Vector{<:Resource}`. +""" +function check_case_data(case) + + case_keys = [:T, :nodes, :links, :products] + key_map = Dict( + :T => TimeStructure, + :nodes => Vector{<:Node}, + :links => Vector{<:Link}, + :products => Vector{<:Resource}, + ) + for key ∈ case_keys + @assert_or_log( + haskey(case, key), + "The `case` dictionary requires the key `:" * string(key) * "` which is " * + "not included." + ) + if haskey(case, key) + @assert_or_log( + isa(case[key], key_map[key]), + "The key `" * string(key) * "` in the `case` dictionary contains " * + "other types than the allowed." + ) + end + end +end + +""" + check_model(case, modeltype::EnergyModel, check_timeprofiles::Bool) + +Checks the `modeltype` . + +## Checks +- All `ResourceEmit`s require a corresponding value in the field `emission_limit`. +- The `emission_limit` time profiles cannot have a finer granulation than `StrategicProfile`. +- The `emission_price` time profiles cannot have a finer granulation than `StrategicProfile`. + +## Conditional checks (if `check_timeprofiles=true`) +- The profiles in `emission_limit` have to have the same length as the number of strategic + periods. +- The profiles in `emission_price` have to have the same length as the number of strategic +periods. +""" +function check_model(case, modeltype::EnergyModel, check_timeprofiles::Bool) -function check_model(case, modeltype::EnergyModel) + 𝒯ᴵⁿᵛ = strategic_periods(case[:T]) + + # Check for inclusion of all emission resources for p ∈ case[:products] if isa(p, ResourceEmit) - @assert_or_log haskey(modeltype.emission_limit, p) "All ResourceEmits requires " * - "an entry in the dictionary GlobalData.Emission_limit. For $p there is none." + @assert_or_log( + haskey(emission_limit(modeltype), p), + "All `ResourceEmit`s require an entry in the dictionary " * + "`emission_limit`. For $p there is none." + ) end end -end + for p ∈ keys(emission_limit(modeltype)) + em_limit = emission_limit(modeltype, p) + # Check for the strategic periods + if isa(em_limit, StrategicProfile) && check_timeprofiles + @assert_or_log( + length(em_limit.vals) == length(𝒯ᴵⁿᵛ), + "The timeprofile provided for resource `" * string(p) * "` in the field " * + "`emission_limit` does not match the strategic structure." + ) + end + + # Check for potential indexing problems + message = "are not allowed for the resource: " * string(p) * " in the Dictionary " * + "`emission_limit`." + check_strategic_profile(em_limit, message) + end + + for p ∈ keys(emission_price(modeltype)) + em_limit = emission_price(modeltype, p) + # Check for the strategic periods + if isa(em_limit, StrategicProfile) && check_timeprofiles + @assert_or_log( + length(em_limit.vals) == length(𝒯ᴵⁿᵛ), + "The timeprofile provided for resource `" * string(p) * "` in the field " * + "`emission_price` does not match the strategic structure." + ) + end + + # Check for potential indexing problems + message = "are not allowed for the resource: " * string(p) * " in the Dictionary " * + "`emission_price`." + check_strategic_profile(em_limit, message) + end +end """ check_time_structure(n::Node, 𝒯) @@ -145,15 +261,20 @@ function check_profile(fieldname, value::StrategicProfile, 𝒯::TwoLevel) len_vals = length(value.vals) len_simp = length(𝒯ᴵⁿᵛ) if len_vals > len_simp - message = "' is shorter than the strategic time structure. \ - Its last values $(len_vals - len_simp) will be omitted." + message = "' is longer than the strategic time structure. \ + Its last $(len_vals - len_simp) value(s) will be omitted." elseif len_vals < len_simp message = "' is shorter than the strategic time structure. It will use the last \ - value for the last $(len_simp - len_vals) strategic periods." + value for the last $(len_simp - len_vals) strategic period(s)." end @assert_or_log len_vals == len_simp "Field '" * string(fieldname) * message for t_inv ∈ 𝒯ᴵⁿᵛ - check_profile(fieldname, value.vals[t_inv.sp], t_inv.operational, t_inv.sp) + check_profile( + fieldname, + value.vals[minimum([t_inv.sp, length(value.vals)])], + t_inv.operational, + t_inv.sp, + ) end end function check_profile(fieldname, value, 𝒯::TwoLevel) @@ -180,11 +301,11 @@ function check_profile( len_simp = length(ts) if len_vals > len_simp message = "' in strategic period $(sp) is longer than the operational time \ - structure. Its last values $(len_vals - len_simp) will be omitted." + structure. Its last $(len_vals - len_simp) value(s) will be omitted." elseif len_vals < len_simp message = "' in strategic period $(sp) is shorter than the operational \ time structure. It will use the last value for the last $(len_simp - len_vals) \ - operational periods." + operational period(s)." end @assert_or_log len_vals == len_simp "Field '" * string(fieldname) * message end @@ -250,12 +371,141 @@ function check_profile( end check_profile(fieldname, value, ts, sp) = nothing +""" + check_strategic_profile(time_profile::TimeProfile, message::String) + +Function for checking that an individual `TimeProfile` does not include the wrong type for +strategic indexing + +## Checks +- `TimeProfile`s access in `StrategicPeriod`s cannot include `OperationalProfile`, \ +`ScenarioProfile`, or `RepresentativeProfile` as this is not allowed through indexing \ +on the `TimeProfile`. +""" +function check_strategic_profile(time_profile::TimeProfile, message::String) + + @assert_or_log( + !isa(time_profile, OperationalProfile), + "Operational profiles " * message + ) + @assert_or_log( + !isa(time_profile, ScenarioProfile), + "Scenario profiles " * message + ) + @assert_or_log( + !isa(time_profile, RepresentativeProfile), + "Representative profiles " * message + ) + + bool_sp = !isa(time_profile, OperationalProfile) * + !isa(time_profile, ScenarioProfile) * + !isa(time_profile, RepresentativeProfile) + + if isa(time_profile, StrategicProfile) + @assert_or_log( + !isa(time_profile.vals, Vector{<:OperationalProfile}), + "Operational profiles in strategic profiles " * message + ) + @assert_or_log( + !isa(time_profile.vals, Vector{<:ScenarioProfile}), + "Scenario profiles in strategic profiles " * message + ) + @assert_or_log( + !isa(time_profile.vals, Vector{<:RepresentativeProfile}), + "Representative profiles in strategic profiles " * message + ) + + bool_sp *= !isa(time_profile.vals, Vector{<:OperationalProfile}) * + !isa(time_profile.vals, Vector{<:ScenarioProfile}) * + !isa(time_profile.vals, Vector{<:RepresentativeProfile}) + end + + return bool_sp +end + +""" + check_representative_profile(time_profile::TimeProfile, message::String) + +Function for checking that an individual `TimeProfile` does not include the wrong type for +representative periods indexing + +## Input +- `time_profile` - The time profile that should be checked. +- `message` - A message that should be printed after the type of profile. + +## Checks +- `TimeProfile`s access in `RepresentativePeriod`s cannot include `OperationalProfile` \ +or `ScenarioProfile` as this is not allowed through indexing on the `TimeProfile`. +""" +function check_representative_profile(time_profile::TimeProfile, message::String) + + @assert_or_log( + !isa(time_profile, OperationalProfile), + "Operational profiles " * message + ) + @assert_or_log( + !isa(time_profile, ScenarioProfile), + "Scenario profiles " * message + ) + + bool_rp = !isa(time_profile, OperationalProfile) * + !isa(time_profile, ScenarioProfile) + + + if isa(time_profile, StrategicProfile) + @assert_or_log( + !isa(time_profile.vals, Vector{<:OperationalProfile}), + "Operational profiles in strategic profiles " * message + ) + @assert_or_log( + !isa(time_profile.vals, Vector{<:ScenarioProfile}), + "Scenario profiles in strategic profiles " * message + ) + + bool_rp *= !isa(time_profile.vals, Vector{<:OperationalProfile}) * + !isa(time_profile.vals, Vector{<:ScenarioProfile}) + end + + return bool_rp +end + +""" + check_scenario_profile(time_profile::TimeProfile, message::String) + +Function for checking that an individual `TimeProfile` does not include the wrong type for +scenario indexing + +## Checks +- `TimeProfile`s access in `RepresentativePeriod`s cannot include `OperationalProfile` \ +or `ScenarioProfile` as this is not allowed through indexing on the `TimeProfile`. +""" +function check_scenario_profile(time_profile::TimeProfile, message::String) + + @assert_or_log( + !isa(time_profile, OperationalProfile), + "Operational profiles " * message + ) + + bool_scp = !isa(time_profile, OperationalProfile) + + if isa(time_profile, StrategicProfile) + @assert_or_log( + !isa(time_profile.vals, Vector{<:OperationalProfile}), + "Operational profiles in strategic profiles " * message + ) + + bool_scp *= !isa(time_profile.vals, Vector{<:OperationalProfile}) + end + + return bool_scp +end + """ check_node(n::Node, 𝒯, modeltype::EnergyModel) Check that the fields of a `Node` corresponds to required structure. """ -function check_node(n::Node, 𝒯, modeltype::EnergyModel) +function check_node(n::Node, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) end """ check_node(n::Availability, 𝒯, modeltype::EnergyModel) @@ -263,7 +513,7 @@ end This method checks that an `Availability` node is valid. By default, that does not include any checks. """ -function check_node(n::Availability, 𝒯, modeltype::EnergyModel) +function check_node(n::Availability, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) end """ check_node(n::Source, 𝒯, modeltype::EnergyModel) @@ -277,9 +527,11 @@ node or that a new `Source` type receives a new method for `check_node`. ## Checks - The field `cap` is required to be non-negative. - 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. + - 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 check_node(n::Source, 𝒯, modeltype::EnergyModel) +function check_node(n::Source, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) @@ -287,14 +539,11 @@ function check_node(n::Source, 𝒯, modeltype::EnergyModel) sum(capacity(n, t) ≥ 0 for t ∈ 𝒯) == length(𝒯), "The capacity must be non-negative." ) - @assert_or_log( - sum(opex_fixed(n, t_inv) ≥ 0 for t_inv ∈ 𝒯ᴵⁿᵛ) == length(𝒯ᴵⁿᵛ), - "The fixed OPEX must be non-negative." - ) @assert_or_log( sum(outputs(n, p) ≥ 0 for p ∈ outputs(n)) == length(outputs(n)), "The values for the Dictionary `output` must be non-negative." ) + check_fixed_opex(n, 𝒯ᴵⁿᵛ, check_timeprofiles) end """ check_node(n::NetworkNode, 𝒯, modeltype::EnergyModel) @@ -309,9 +558,11 @@ important that a new `NetworkNode` type includes at least the same fields as in - 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 non-negative. - - The value of the field `fixed_opex` is 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 + `check_fixed_opex(n, 𝒯ᴵⁿᵛ, check_timeprofiles)`. """ -function check_node(n::NetworkNode, 𝒯, modeltype::EnergyModel) +function check_node(n::NetworkNode, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) @@ -327,10 +578,7 @@ function check_node(n::NetworkNode, 𝒯, modeltype::EnergyModel) sum(outputs(n, p) ≥ 0 for p ∈ outputs(n)) == length(outputs(n)), "The values for the Dictionary `output` must be non-negative." ) - @assert_or_log( - sum(opex_fixed(n, t_inv) ≥ 0 for t_inv ∈ 𝒯ᴵⁿᵛ) == length(𝒯ᴵⁿᵛ), - "The fixed OPEX must be non-negative." - ) + check_fixed_opex(n, 𝒯ᴵⁿᵛ, check_timeprofiles) end """ check_node(n::Storage, 𝒯, modeltype::EnergyModel) @@ -346,9 +594,11 @@ important that a new `Storage` type includes at least the same fields as in the - The value of the field `stor_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 non-negative. - - The value of the field `fixed_opex` is 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 + `check_fixed_opex(n, 𝒯ᴵⁿᵛ, check_timeprofiles)`. """ -function check_node(n::Storage, 𝒯, modeltype::EnergyModel) +function check_node(n::Storage, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) 𝒯ᴵⁿᵛ = strategic_periods(𝒯) cap = capacity(n) @@ -369,10 +619,7 @@ function check_node(n::Storage, 𝒯, modeltype::EnergyModel) sum(outputs(n, p) ≥ 0 for p ∈ outputs(n)) == length(outputs(n)), "The values for the Dictionary `output` must be non-negative." ) - @assert_or_log( - sum(opex_fixed(n, t_inv) ≥ 0 for t_inv ∈ 𝒯ᴵⁿᵛ) == length(𝒯ᴵⁿᵛ), - "The fixed OPEX must be non-negative." - ) + check_fixed_opex(n, 𝒯ᴵⁿᵛ, check_timeprofiles) end """ check_node(n::Sink, 𝒯, modeltype::EnergyModel) @@ -390,7 +637,7 @@ or that a new `Source` type receives a new method for `check_node`. - The sum of the values `:deficit` and `:surplus` in the dictionary `penalty` has to be \ non-negative to avoid an infeasible model. """ -function check_node(n::Sink, 𝒯, modeltype::EnergyModel) +function check_node(n::Sink, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) @assert_or_log( sum(capacity(n, t) ≥ 0 for t ∈ 𝒯) == length(𝒯), "The capacity must be non-negative." @@ -412,20 +659,53 @@ function check_node(n::Sink, 𝒯, modeltype::EnergyModel) ) end end +""" + check_fixed_opex(n::Node, 𝒯ᴵⁿᵛ, check_timeprofiles::Bool) + +Checks that the fixed opex value follows the given TimeStructure. + +## Checks +- The `opex_fixed` time profile cannot have a finer granulation than `StrategicProfile`. +## Conditional checks (if `check_timeprofiles=true`) +- The profiles in `opex_fixed` have to have the same length as the number of strategic + periods. +""" +function check_fixed_opex(n::Node, 𝒯ᴵⁿᵛ, check_timeprofiles::Bool) + + if isa(opex_fixed(n), StrategicProfile) && check_timeprofiles + @assert_or_log( + length(opex_fixed(n).vals) == length(𝒯ᴵⁿᵛ), + "The timeprofile provided for the field `opex_fixed` does not match the " * + "strategic structure." + ) + end + + # Check for potential indexing problems + message = "are not allowed for the field `opex_fixed`." + bool_sp = check_strategic_profile(opex_fixed(n), message) + + # Check that the value is positive in all cases + if bool_sp + @assert_or_log( + sum(opex_fixed(n, t_inv) ≥ 0 for t_inv ∈ 𝒯ᴵⁿᵛ) == length(𝒯ᴵⁿᵛ), + "The fixed OPEX must be non-negative." + ) + end +end """ - check_node_data(n::Node, data::Data, 𝒯, modeltype::EnergyModel) + check_node_data(n::Node, data::Data, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) Check that the included `Data` types of a `Node` corresponds to required structure. This function will always result in a multiple error message, if several instances of the same supertype is loaded. """ -check_node_data(n::Node, data::Data, 𝒯, modeltype::EnergyModel) = nothing +check_node_data(n::Node, data::Data, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) = nothing """ - check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel) + check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) Check that the included `Data` types of a `Node` corresponds to required structure. This function will always result in a multiple error message, if several instances of the @@ -437,7 +717,7 @@ same supertype is loaded. - The value of the field `co2_capture` is required to be in the range ``[0, 1]``, if \ [`CaptureData`](@ref) is used. """ -function check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel) +function check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) em_data = filter(data -> typeof(data) <: EmissionsData, node_data(n)) @assert_or_log( @@ -450,11 +730,12 @@ function check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyMo for p ∈ process_emissions(data) value = process_emissions(data, p) + !check_timeprofiles && continue !isa(value, TimeProfile) && continue check_profile(string(p)*" process emissions", value, 𝒯) end end -function check_node_data(n::Node, data::CaptureData, 𝒯, modeltype::EnergyModel) +function check_node_data(n::Node, data::CaptureData, 𝒯, modeltype::EnergyModel, check_timeprofiles::Bool) em_data = filter(data -> typeof(data) <: EmissionsData, node_data(n)) @assert_or_log( @@ -464,6 +745,7 @@ function check_node_data(n::Node, data::CaptureData, 𝒯, modeltype::EnergyMode for p ∈ process_emissions(data) value = process_emissions(data, p) + !check_timeprofiles && continue !isa(value, TimeProfile) && continue check_profile(string(p)*" process emissions", value, 𝒯) end diff --git a/src/model.jl b/src/model.jl index 589c837..e7b87fb 100644 --- a/src/model.jl +++ b/src/model.jl @@ -1,21 +1,33 @@ """ - create_model(case, modeltype::EnergyModel) + create_model(case, modeltype::EnergyModel, m::JuMP.Model; check_timeprofiles::Bool=true) -Create the model and call all required functions based on provided `modeltype` -and case data. +Create the model and call all required functions. + +## Input +- `case` - The case dictionary requiring the keys `:T`, `:nodes`, `:links`, and `products`. + If the input is not provided in the correct form, the checks will identify the problem. +- `modeltype` - Used modeltype, that is a subtype of the type `EnergyModel`. +- `m` - the empty `JuMP.Model` instance. If it is not provided, then it is assumed that the + input is a standard `JuMP.Model`. + +## Conditional input +- `check_timeprofiles=true` - A boolean indicator whether the time profiles of the individual + nodes should be checked or not. It is advised to not deactivate the check, except if you + are testing new components. It may lead to unexpected behaviour and potential + inconsistencies in the input data, if the time profiles are not checked. """ -function create_model(case, modeltype::EnergyModel, m::JuMP.Model) +function create_model(case, modeltype::EnergyModel, m::JuMP.Model; check_timeprofiles::Bool=true) @debug "Construct model" + # Check if the case data is consistent before the model is created. + check_data(case, modeltype, check_timeprofiles) + # WIP Data structure 𝒯 = case[:T] 𝒩 = case[:nodes] ℒ = case[:links] 𝒫 = case[:products] - # Check if the case data is consistent before the model is created. - check_data(case, modeltype) - # Declaration of variables for the problem variables_flow(m, 𝒩, 𝒯, 𝒫, ℒ, modeltype) variables_emission(m, 𝒩, 𝒯, 𝒫, modeltype) @@ -34,9 +46,9 @@ function create_model(case, modeltype::EnergyModel, m::JuMP.Model) return m end -function create_model(case, modeltype::EnergyModel) +function create_model(case, modeltype::EnergyModel; check_timeprofiles::Bool=true) m = JuMP.Model() - create_model(case, modeltype::EnergyModel, m) + create_model(case, modeltype, m; check_timeprofiles) end """ diff --git a/src/utils.jl b/src/utils.jl index 7b3ba37..f26bd99 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -10,10 +10,10 @@ The dictionary requires the keys: - `:products::Vector{Resource}` - `:T::TimeStructure` """ -function run_model(case::Dict, model::EnergyModel, optimizer) +function run_model(case::Dict, model::EnergyModel, optimizer; check_timeprofiles=true) @debug "Run model" optimizer - m = create_model(case, model) + m = create_model(case, model; check_timeprofiles) if !isnothing(optimizer) set_optimizer(m, optimizer) diff --git a/test/test_checks.jl b/test/test_checks.jl index 941aa2f..194a555 100644 --- a/test/test_checks.jl +++ b/test/test_checks.jl @@ -1,6 +1,179 @@ # Set the global to true to suppress the error message EMB.TEST_ENV = true +@testset "Test checks - case dictionary" begin + # Resources used in the analysis + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Function for setting up the system + function simple_graph() + + resources = [Power, CO2] + ops = SimpleTimes(5, 2) + T = TwoLevel(2, 2, ops; op_per_strat=10) + + source = RefSource( + "source_emit", + FixedProfile(4), + FixedProfile(0), + FixedProfile(10), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(-4), :deficit => FixedProfile(4)), + Dict(Power => 1), + ) + + ops = SimpleTimes(5, 2) + T = TwoLevel(2, 2, ops; op_per_strat=10) + + nodes = [source, sink] + links = [Direct(12, source, sink)] + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => FixedProfile(0)), + CO2 + ) + case = Dict( + :T => T, + :nodes => nodes, + :links => links, + :products => resources, + ) + return case, model + end + + # Check that the keys are present + # - EMB.check_case_data(case) + case, model = simple_graph() + for key ∈ keys(case) + case_test = deepcopy(case) + pop!(case_test, key) + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + end + + # Check that the keys are of the correct format and do not include any unwanted types + # - EMB.check_case_data(case) + case_test = deepcopy(case) + case_test[:T] = 10 + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + case_test = deepcopy(case) + case_test[:nodes] = [case[:nodes], case[:nodes], 10] + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + case_test = deepcopy(case) + case_test[:links] = [case[:links], 10] + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + case_test = deepcopy(case) + case_test[:products] = [case[:products], case[:products], 10] + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) +end + +@testset "Test checks - modeltype" begin + # Resources used in the analysis + Power = ResourceCarrier("Power", 0.0) + CO2 = ResourceEmit("CO2", 1.0) + + # Function for setting up the system + function simple_graph() + + resources = [Power, CO2] + ops = SimpleTimes(5, 2) + T = TwoLevel(2, 2, ops; op_per_strat=10) + + source = RefSource( + "source_emit", + FixedProfile(4), + FixedProfile(0), + FixedProfile(10), + Dict(Power => 1), + ) + sink = RefSink( + "sink", + FixedProfile(3), + Dict(:surplus => FixedProfile(-4), :deficit => FixedProfile(4)), + Dict(Power => 1), + ) + + ops = SimpleTimes(5, 2) + T = TwoLevel(2, 2, ops; op_per_strat=10) + + nodes = [source, sink] + links = [Direct(12, source, sink)] + case = Dict( + :T => T, + :nodes => nodes, + :links => links, + :products => resources, + ) + return case + end + + # Create a function for running the simple graph + function run_simple_graph(co2_limit::TS.TimeProfile) + case = simple_graph() + model = OperationalModel( + Dict(CO2 => co2_limit), + Dict(CO2 => FixedProfile(0)), + CO2 + ) + return create_model(case, model), case, model + end + + # Extract the default values + case = simple_graph() + model = OperationalModel( + Dict(CO2 => FixedProfile(100)), + Dict(CO2 => FixedProfile(0)), + CO2 + ) + + # Check that all resources present in the case data are included in the emission limit + # - EMB.check_model(case, modeltype::EnergyModel, check_timeprofiles) + case_test = deepcopy(case) + case_test[:products] = [Power, CO2, ResourceEmit("NG", 0.06)] + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + + # Check that the timeprofiles for emission limit and price are correct + # - EMB.check_model(case, modeltype::EnergyModel, check_timeprofiles) + model = OperationalModel( + Dict(CO2 => StrategicProfile([100])), + Dict(CO2 => FixedProfile(0)), + CO2 + ) + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + model = OperationalModel( + Dict(CO2 => FixedProfile(0)), + Dict(CO2 => StrategicProfile([100])), + CO2 + ) + @test_throws AssertionError run_model(case_test, model, HiGHS.Optimizer) + + # Check that we receive an error if the profiles are wrong + # - check_strategic_profile(time_profile, message) + rprofile = RepresentativeProfile([FixedProfile(100)]) + scprofile = ScenarioProfile([FixedProfile(100)]) + oprofile = OperationalProfile(ones(100)) + + co2_limit = oprofile + @test_throws AssertionError run_simple_graph(co2_limit) + co2_limit = scprofile + @test_throws AssertionError run_simple_graph(co2_limit) + co2_limit = rprofile + @test_throws AssertionError run_simple_graph(co2_limit) + co2_limit = StrategicProfile([4]) + @test_throws AssertionError run_simple_graph(co2_limit) + + co2_limit = StrategicProfile([oprofile, oprofile, oprofile, oprofile]) + @test_throws AssertionError run_simple_graph(co2_limit) + co2_limit = StrategicProfile([scprofile, scprofile, scprofile, scprofile]) + @test_throws AssertionError run_simple_graph(co2_limit) + co2_limit = StrategicProfile([rprofile, rprofile, rprofile, rprofile]) + @test_throws AssertionError run_simple_graph(co2_limit) +end + @testset "Test checks - emission data" begin # Resources used in the analysis Power = ResourceCarrier("Power", 0.0) @@ -44,21 +217,42 @@ EMB.TEST_ENV = true :links => links, :products => resources, ) - return run_model(case, model, HiGHS.Optimizer), case, model + return case, model + end + + # Create a function for running the simple graph + function run_simple_graph(em_data::Vector{<:EmissionsData}) + case, model = simple_graph(em_data) + return create_model(case, model), case, model end # Test that only a single EmissionData is allowed # - EMB.check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel) em_data = [EmissionsProcess(Dict(CO2 => 10.0)), EmissionsEnergy()] - @test_throws AssertionError simple_graph(em_data) + @test_throws AssertionError run_simple_graph(em_data) # Test that the capture rate is bound by 0 and 1 # - EMB.check_node_data(n::Node, data::CaptureData, 𝒯, modeltype::EnergyModel) em_data = [CaptureEnergyEmissions(1.2)] - @test_throws AssertionError simple_graph(em_data) - + @test_throws AssertionError run_simple_graph(em_data) em_data = [CaptureEnergyEmissions(-1.2)] - @test_throws AssertionError simple_graph(em_data) + @test_throws AssertionError run_simple_graph(em_data) + + # Test that the timeprofile check is working and only showing the warning once + # - EMB.check_node_data(n::Node, data::EmissionsData, 𝒯, modeltype::EnergyModel + em_data = [EmissionsProcess(Dict(CO2 => StrategicProfile([1])))] + @test_throws AssertionError run_simple_graph(em_data) + case, model = simple_graph(em_data) + msg = "Checking of the time profiles is deactivated:\n" * + "Deactivating the checks for the time profiles is strongly discouraged. " * + "While the model will still run, unexpected results can occur, as well as " * + "inconsistent case data.\n\n" * + "Deactivating the checks for the timeprofiles should only be considered, " * + "when testing new components. In all other instances, it is recommended to " * + "provide the correct timeprofiles using a preprocessing routine.\n\n" * + "If timeprofiles are not checked, inconsistencies can occur." + @test_logs (:warn, msg) create_model(case, model; check_timeprofiles=false) + @test_logs (:warn, msg) for k ∈ [1,2] create_model(case, model; check_timeprofiles=false) end end @@ -106,7 +300,13 @@ end :links => links, :products => resources, ) - return run_model(case, model, HiGHS.Optimizer), case, model + return case, model + end + + # Create a function for running the simple graph + function run_simple_graph(T::TimeStructure, tp::TimeProfile) + case, model = simple_graph(T, tp) + return create_model(case, model), case, model end day = SimpleTimes(24, 1) @@ -116,15 +316,15 @@ end ts = TwoLevel(2, 1, day) ops = OperationalProfile(ones(24)) tp = StrategicProfile([ops, ops, ops]) - @test_throws AssertionError simple_graph(ts, tp) + @test_throws AssertionError run_simple_graph(ts, tp) # Test that there is an error with wrong operational profiles # - EMB.check_profile(fieldname, value::OperationalProfile, ts::SimpleTimes, sp) ts = TwoLevel(2, 1, day) tp = OperationalProfile(ones(20)) - @test_throws AssertionError simple_graph(ts, tp) + @test_throws AssertionError run_simple_graph(ts, tp) tp = OperationalProfile(ones(30)) - @test_throws AssertionError simple_graph(ts, tp) + @test_throws AssertionError run_simple_graph(ts, tp) # Test that there is warning when using OperationalProfile with RepresentativePeriods # - EMB.check_profile(fieldname, value::RepresentativeProfile, ts::SimpleTimes, sp) @@ -135,7 +335,7 @@ end It only works reasonable if all representative periods have an operational \ time structure of the same length. Otherwise, the last value is repeated. \ The system is tested for the all representative periods." - @test_logs (:warn, msg) simple_graph(ts, tp) + @test_logs (:warn, msg) run_simple_graph(ts, tp) # Test that there is warning when using RepresentativeProfile without RepresentativePeriods # - EMB.check_profile(fieldname, value::RepresentativeProfile, ts::SimpleTimes, sp) @@ -143,13 +343,25 @@ end tp = RepresentativeProfile([FixedProfile(5), FixedProfile(10)]) msg = "Field cap: Using `RepresentativeProfile` with `SimpleTimes` is dangerous, as it \ may lead to unexpected behaviour. In this case, only the first profile is used and tested." - @test_logs (:warn, msg) simple_graph(ts, tp) + @test_logs (:warn, msg) run_simple_graph(ts, tp) # Test that there is an error when `RepresentativeProfile` have a different length than # the corresponding `RepresentativePeriods` # - EMB.check_profile(fieldname, value::RepresentativeProfile, ts::SimpleTimes, sp) ts = TwoLevel(2, 1, RepresentativePeriods(3, 8760, ones(3)/3, [day, day, day])) - @test_throws AssertionError simple_graph(ts, tp) + @test_throws AssertionError run_simple_graph(ts, tp) + + # Check that turning of the timeprofile checks leads to a warning + case, model = simple_graph(ts, tp) + msg = "Checking of the time profiles is deactivated:\n" * + "Deactivating the checks for the time profiles is strongly discouraged. " * + "While the model will still run, unexpected results can occur, as well as " * + "inconsistent case data.\n\n" * + "Deactivating the checks for the timeprofiles should only be considered, " * + "when testing new components. In all other instances, it is recommended to " * + "provide the correct timeprofiles using a preprocessing routine.\n\n" * + "If timeprofiles are not checked, inconsistencies can occur." + @test_logs (:warn, msg) create_model(case, model; check_timeprofiles=false) end @testset "Test checks - Nodes" begin @@ -179,7 +391,7 @@ end :links => links, :products => resources, ) - return run_model(case, model, HiGHS.Optimizer), case, model + return create_model(case, model), case, model end # Test that the fields of a Source are correctly checked @@ -214,14 +426,25 @@ end @test_throws AssertionError simple_graph(source, sink) # Test that a wrong fixed OPEX is caught by the checks. - source = RefSource( - "source", - FixedProfile(4), - FixedProfile(10), - FixedProfile(-5), - Dict(Power => 1), - ) - @test_throws AssertionError simple_graph(source, sink) + function check_opex_prof(opex_fixed, sink) + source = RefSource( + "source", + FixedProfile(4), + FixedProfile(10), + opex_fixed, + Dict(Power => 1), + ) + return simple_graph(source, sink) + end + opex_fixed = FixedProfile(-5) + @test_throws AssertionError check_opex_prof(opex_fixed, sink) + + # Test that a wrong profile for fixed OPEX is caught by the checks. + # - check_fixed_opex(n::Node, 𝒯ᴵⁿᵛ, check_timeprofiles::Bool) + opex_fixed = StrategicProfile([1]) + @test_throws AssertionError check_opex_prof(opex_fixed, sink) + opex_fixed = OperationalProfile([1]) + @test_throws AssertionError check_opex_prof(opex_fixed, sink) # Test that correct input solves the model to optimality. source = RefSource( @@ -231,7 +454,8 @@ end FixedProfile(5), Dict(Power => 1), ) - m , _, _ = simple_graph(source, sink) + _, case, model = simple_graph(source, sink) + m = run_model(case, model, HiGHS.Optimizer) @test termination_status(m) == MOI.OPTIMAL end @@ -282,7 +506,8 @@ end Dict(:surplus => FixedProfile(4), :deficit => FixedProfile(10)), Dict(Power => 1), ) - m , _, _ = simple_graph(source, sink) + _, case, model = simple_graph(source, sink) + m = run_model(case, model, HiGHS.Optimizer) @test termination_status(m) == MOI.OPTIMAL end @@ -326,7 +551,7 @@ end :links => links, :products => resources, ) - return run_model(case, model, HiGHS.Optimizer), case, model + return create_model(case, model), case, model end # Test that the fields of a NetworkNode are correctly checked @@ -386,7 +611,8 @@ end Dict(NG => 2), Dict(Power => 1), ) - m , _, _ = simple_graph(network) + _, case, model = simple_graph(network) + m = run_model(case, model, HiGHS.Optimizer) @test termination_status(m) == MOI.OPTIMAL end @@ -438,7 +664,7 @@ end :links => links, :products => resources, ) - return run_model(case, model, HiGHS.Optimizer), case, model + return create_model(case, model), case, model end # Test that the fields of a Storage are correctly checked @@ -532,7 +758,8 @@ end Dict(Power => 1, aux => 0.05), Dict(Power => 1), ) - m , _, _ = simple_graph(storage) + _, case, model = simple_graph(storage) + m = run_model(case, model, HiGHS.Optimizer) @test termination_status(m) == MOI.OPTIMAL end end diff --git a/test/test_utils.jl b/test/test_utils.jl index d1e0aff..95b1a02 100644 --- a/test/test_utils.jl +++ b/test/test_utils.jl @@ -63,7 +63,6 @@ end end function test_type_order(sorted_node_types) - @info sorted_node_types indexes = Dict(sorted_node_types .=> keys(sorted_node_types)) @test indexes[EMB.Node] == 1