diff --git a/.github/actions/before-script/action.yml b/.github/actions/before-script/action.yml index ea412e0..5e56526 100644 --- a/.github/actions/before-script/action.yml +++ b/.github/actions/before-script/action.yml @@ -24,6 +24,7 @@ runs: cd "$GITHUB_WORKSPACE/submodules/wind_power_timeseries" poetry install + pip install metocean-api==1.1.14 cd "$GITHUB_WORKSPACE" cd "$GITHUB_WORKSPACE/submodules/Tecnalia_Solar-Energy-Model" @@ -70,6 +71,7 @@ runs: poetry install Set-Location (Join-Path $env:GITHUB_WORKSPACE "submodules\wind_power_timeseries") poetry install + pip install metocean-api==1.1.14 Set-Location (Join-Path $env:GITHUB_WORKSPACE "submodules\Tecnalia_Solar-Energy-Model") poetry install Set-Location (Join-Path $env:GITHUB_WORKSPACE "submodules\Tecnalia_Building-Stock-Energy-Model") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86a955a..c308792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,9 @@ jobs: Add-Content $env:GITHUB_ENV "SSL_CERT_FILE=$ca" - name: Run Julia tests inside the testenv-enviornment in Miniconda (Linux) if: runner.os == 'Linux' + env: + CDSAPI_URL: https://cds.climate.copernicus.eu/api + CDSAPI_KEY: ${{ secrets.CDSAPI_KEY }} run: | source "$HOME/miniconda/etc/profile.d/conda.sh" conda init bash @@ -75,6 +78,9 @@ jobs: shell: bash - name: Run Julia tests inside the testenv-environment in Miniconda (Windows) if: runner.os == 'Windows' + env: + CDSAPI_URL: https://cds.climate.copernicus.eu/api + CDSAPI_KEY: ${{ secrets.CDSAPI_KEY }} run: | $conda = Join-Path $env:USERPROFILE "miniconda\Scripts\conda.exe" & $conda "shell.powershell" "hook" | Out-String | Invoke-Expression diff --git a/NEWS.md b/NEWS.md index b726e37..f0cd9db 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,13 @@ # Release notes -## Version 0.1.0 (2026-04-15) +## Version 0.1.0 (2026-04-25) + +### Add Building node + +* Added a `Building` node to the package, which provides sampling routines for building heat demand profiles based on temperature data. + A function `heat_demand_profile` is included to generate the heat demand profile from temperature data downloaded using hindcast data. + A function `get_met_data` is included to handle data retrieval, caching, and storage (using the python [`metocean_api`](https://metocean-api.readthedocs.io/en/latest/) library) for the meteorological data used in the `heat_demand_profile` function. + This function enables implementation of meteorological data dependent nodes. ### Add PV node diff --git a/Project.toml b/Project.toml index c023a41..d495503 100644 --- a/Project.toml +++ b/Project.toml @@ -28,10 +28,10 @@ EMIExt = "EnergyModelsInvestments" CSV = "0.10.16" DataFrames = "1.8.1" Dates = "1.10" -EnergyModelsBase = "0.9.0" -EnergyModelsHeat = "0.1.1" -EnergyModelsInvestments = "0.8.1" -EnergyModelsRenewableProducers = "0.6.5" +EnergyModelsBase = "0.10" +EnergyModelsHeat = "0.2" +EnergyModelsInvestments = "0.9" +EnergyModelsRenewableProducers = "0.7" HTTP = "1.11" JSON = "1.5.0" JuMP = "1.23" diff --git a/docs/make.jl b/docs/make.jl index b471828..d28b996 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -45,7 +45,7 @@ makedocs( "WindPower"=>"nodes/windpower.md", "PV"=>"nodes/pv.md", "PVandCSP"=>"nodes/pvandcsp.md", - "MultipleBuildingTypes"=>"nodes/multiplebuildingtypes.md", + "Buildings"=>"nodes/multiplebuildingtypes.md", ], "Resources" => Any["ResourceBio"=>"resources/resourcebio.md"], "Utility functions" => Any["Reference"=>"util-fun/reference.md"], @@ -55,6 +55,7 @@ makedocs( "Library" => Any[ "Public"=>"library/public.md", "Internals"=>String[ + "library/internals/types-EMLI.md", "library/internals/methods-EMLI.md", "library/internals/methods-EMB.md", "library/internals/methods-EMR.md", diff --git a/docs/src/how-to/utilize.md b/docs/src/how-to/utilize.md index 98b06ab..3648ef5 100644 --- a/docs/src/how-to/utilize.md +++ b/docs/src/how-to/utilize.md @@ -189,7 +189,9 @@ cd ../.. !!! note "Environments" If you are a developer, you probably want to install the python modules in a separate environment which can be done with, *e.g.*, [miniconda](https://www.anaconda.com/docs/getting-started/miniconda/install). -Enable these by starting a julia session in the main folder +### [Enable python modules](@id how_to-utilize-use_nodes-enable_python_modules) + +The python modules above (and other modules) can be enabled by starting a julia session in the main folder ```PowerShell julia --project=. diff --git a/docs/src/library/internals/methods-EMLI.md b/docs/src/library/internals/methods-EMLI.md index 3854e25..c720c4f 100644 --- a/docs/src/library/internals/methods-EMLI.md +++ b/docs/src/library/internals/methods-EMLI.md @@ -9,14 +9,16 @@ Pages = ["methods-EMLI.md"] ## [Utility methods](@id lib-int-met-util) ```@docs -EnergyModelsLanguageInterfaces.cleanup_libraries -EnergyModelsLanguageInterfaces.getfirst -EnergyModelsLanguageInterfaces.get_python_function -EnergyModelsLanguageInterfaces.moisture -EnergyModelsLanguageInterfaces.bio_type -EnergyModelsLanguageInterfaces.electricity_resource -EnergyModelsLanguageInterfaces.pvgis_profile -EnergyModelsLanguageInterfaces.get_pvgis_data +EMLI.cleanup_libraries +EMLI.getfirst +EMLI.get_python_function +EMLI.moisture +EMLI.bio_type +EMLI.electricity_resource +EMLI.pvgis_profile +EMLI.get_pvgis_data +EMLI.get_met_data +EMLI.heat_demand_profile ``` ## [Macros](@id lib-int-mac-util) diff --git a/docs/src/library/internals/types-EMLI.md b/docs/src/library/internals/types-EMLI.md new file mode 100644 index 0000000..12c93be --- /dev/null +++ b/docs/src/library/internals/types-EMLI.md @@ -0,0 +1,13 @@ +# [Types](@id lib-int-types) + +## [Index](@id lib-int-types-idx) + +```@index +Pages = ["types-EMLI.md"] +``` + +## [Nodal supertypes](@id lib-int-types-node) + +```@docs +EMLI.AbstractBuildings +``` \ No newline at end of file diff --git a/docs/src/library/public.md b/docs/src/library/public.md index ad44468..cf233c1 100644 --- a/docs/src/library/public.md +++ b/docs/src/library/public.md @@ -3,29 +3,30 @@ ## [New resource types](@id lib-pub-resource_types) ```@docs -EnergyModelsLanguageInterfaces.ResourceBio +EMLI.ResourceBio ``` ## [New parameter types](@id lib-pub-parameter_types) ```@docs -EnergyModelsLanguageInterfaces.PVParameters +EMLI.PVParameters ``` ## [New nodal types](@id lib-pub-nodal_types) ```@docs -EnergyModelsLanguageInterfaces.WindPower -EnergyModelsLanguageInterfaces.PV -EnergyModelsLanguageInterfaces.CSPandPV -EnergyModelsLanguageInterfaces.MultipleBuildingTypes -EnergyModelsLanguageInterfaces.BioCHP +EMLI.WindPower +EMLI.PV +EMLI.CSPandPV +EMLI.Building +EMLI.MultipleBuildingTypes +EMLI.BioCHP ``` ## [Sampling constructors](@id lib-pub-sampling_constructors) ```@docs -EnergyModelsLanguageInterfaces.WindPower( +EMLI.WindPower( ::Any, ::TimeStruct.TimeProfile, ::Dict, @@ -35,7 +36,7 @@ EnergyModelsLanguageInterfaces.WindPower( ::TimeStruct.TimeProfile, ::Dict{<:EnergyModelsBase.Resource,<:Real}, ) -EnergyModelsLanguageInterfaces.PV( +EMLI.PV( ::Any, ::TimeProfile, ::TimeProfile, @@ -44,22 +45,40 @@ EnergyModelsLanguageInterfaces.PV( ::DateTime, ::DateTime, ::PVParameters; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_path::String = "pvgis_cache", filename_hint::String = "", ) -EnergyModelsLanguageInterfaces.CSPandPV( +EMLI.CSPandPV( ::Any, ::Dict, ::DateTime, ::DateTime, ::Dict{String,<:EnergyModelsBase.Resource}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], method::String = "Ninja", data_path::String = "", source::String = "NORA3", ) -EnergyModelsLanguageInterfaces.MultipleBuildingTypes( +EMLI.Building( + ::Any, + ::Dict{<:Resource,<:TimeProfile}, + ::Dict{<:Resource,<:TimeProfile}, + ::Dict{<:Resource,<:TimeProfile}, + ::Dict{<:Resource,<:Real}, + ::DateTime, + ::DateTime, + ::Real, + ::Real, + ::Resource, + ::Function; + data::Vector{<:ExtensionData} = ExtensionData[], + data_path::String = joinpath(tempdir(), "building"), + source::String = "NORA3", + reload_csv::Bool = true, + save_csv::Bool = true, +) +EMLI.MultipleBuildingTypes( ::Any, ::Dict, ::DateTime, @@ -69,17 +88,17 @@ EnergyModelsLanguageInterfaces.MultipleBuildingTypes( ::TimeStruct.TimeStructure, ::Dict{<:EnergyModelsBase.Resource,<:TimeStruct.TimeProfile}, ::Dict{<:EnergyModelsBase.Resource,<:TimeStruct.TimeProfile}; - data::Vector{<:EnergyModelsBase.Data} = EnergyModelsBase.Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_location::String = joinpath(tempdir(), "buildings"), overwrite_saved_data::Bool = false, ) -EnergyModelsLanguageInterfaces.BioCHP( +EMLI.BioCHP( ::Any, ::TimeStruct.TimeProfile, - ::Dict{<:EnergyModelsLanguageInterfaces.ResourceBio,<:Real}, + ::Dict{<:EMLI.ResourceBio,<:Real}, ::Dict{<:EnergyModelsHeat.ResourceHeat,<:Real}, ::EnergyModelsBase.Resource; - data::Vector{<:EnergyModelsBase.Data} = EnergyModelsBase.Data[], + data::Vector{<:ExtensionData} = ExtensionData[], libpath::String = joinpath(@__DIR__, "..", "..", "CHP_modelling", "build", "lib", "libbioCHP_wrapper.so"), ) ``` @@ -87,6 +106,6 @@ EnergyModelsLanguageInterfaces.BioCHP( ## [Utility functions](@id lib-pub-util_fun) ```@docs -EnergyModelsLanguageInterfaces.call_python_function -EnergyModelsLanguageInterfaces.fetch_element +EMLI.call_python_function +EMLI.fetch_element ``` diff --git a/docs/src/nodes/multiplebuildingtypes.md b/docs/src/nodes/multiplebuildingtypes.md index 9ad82d0..47fb4c8 100644 --- a/docs/src/nodes/multiplebuildingtypes.md +++ b/docs/src/nodes/multiplebuildingtypes.md @@ -1,24 +1,38 @@ -# [Multiple building types sink node](@id nodes-MultipleBuildingTypes) +# [Building nodes](@id nodes-AbstractBuildings) -The [`MultipleBuildingTypes`](@ref) node creates sinks for all demand resources with penalties for both surplus and deficit. +The [`AbstractBuildings`](@ref EMLI.AbstractBuildings) node types create sinks for all demand resources with penalties for both surplus and deficit. The implementation uses `Dict` structures for the fields `cap`, `penalty_surplus`, and `penalty_deficit` to facilitate multiple [Resource](@extref EnergyModelsBase.Resource)s. This approach allows modeling building demands with flexible penalty mechanisms for over- and under-supply. -The type is also used to enable a specialized constructor that samples the [`Tecnalia_Building-Stock-Energy-Model`](https://github.com/iDesignRES/Tecnalia_Building-Stock-Energy-Model) module. + +!!! danger + Investments are currently not available for these nodes. + +## [Introduced types and their fields](@id nodes-AbstractBuildings-fields) + +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes are subtypes of [`Sink`](@extref EnergyModelsBase.Sink), and hence, correspond to specialized sink nodes. + +The subtype [`MultipleBuildingTypes`](@ref) is used to enable a specialized constructor that samples the [`Tecnalia_Building-Stock-Energy-Model`](https://github.com/iDesignRES/Tecnalia_Building-Stock-Energy-Model) module. + +The subtype [`Building`](@ref) can be used to model a simple building demand where the heat demand is calculated from the temperature at a single location and a user-defined temperature-to-demand function. +See the [constructor](@ref lib-pub-sampling_constructors) for more details. !!! note "Sampling Tecnalia_Building-Stock-Energy-Model module" To use the [constructor](@ref lib-pub-sampling_constructors) for [`MultipleBuildingTypes`](@ref) that samples the [`Tecnalia_Building-Stock-Energy-Model`](https://github.com/iDesignRES/Tecnalia_Building-Stock-Energy-Model) module, follow the installation in the [Use nodes](@ref how_to-utilize-use_nodes) section. -!!! danger - Investments are currently not available for this node. +!!! note "Using the sampling constructor for `Building` nodes" + The sampling [constructor](@ref lib-pub-sampling_constructors) for [`Building`](@ref) node requires the installation of the python package `metocean_api` which can be done with (there is a breaking change in version 1.1.14 which is currently not available in Conda, so we recommend installing it with pip): + + ```bash + pip install metocean-api + ``` -## [Introduced type and its field](@id nodes-MultipleBuildingTypes-fields) + Enable this package in julia by following the instructions in the [Enable python modules](@ref how_to-utilize-use_nodes-enable_python_modules) section. -The [`MultipleBuildingTypes`](@ref) is a subtype of [`Sink`](@extref EnergyModelsBase.Sink) and is implemented as a specialized sink node. -Hence, it utilizes the same functions declared in `EnergyModelsBase`. + Note that this package is already available if the module wind_power_timeseries is installed as described in the [Install python modules](@ref how_to-utilize-use_nodes) section. -### [Standard fields](@id nodes-MultipleBuildingTypes-fields-stand) +### [Standard fields](@id nodes-AbstractBuildings-fields-stand) -Standard fields of a [`MultipleBuildingTypes`](@ref) node are given as: +Standard fields of [AbstractBuildings](@ref EMLI.AbstractBuildings) nodes are given as: - **`id`**:\ The field `id` is only used for providing a name to the node. @@ -26,20 +40,21 @@ Standard fields of a [`MultipleBuildingTypes`](@ref) node are given as: - **`input::Dict{<:Resource, <:Real}`**:\ The field `input` includes [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries. All values have to be non-negative. -- **`data::Vector{Data}`**:\ +- **`data::Vector{<:ExtensionData}`**:\ An entry for providing additional data to the model. - In the current version, it is not applicable. We intend to change this in future releases to enable investments. + In the current version, it is not applicable. + We intend to change this in future releases to enable investments. - !!! note "Constructor for `MultipleBuildingTypes`" + !!! note "Constructor for `AbstractBuildings` nodes" The field `data` is not required as we include a constructor when the value is excluded. !!! danger "Using `CaptureData`" As a `Sink` node does not have any output, it is not possible to utilize `CaptureData`. If you still plan to specify it, you will receive an error in the model building. -### [Additional fields](@id nodes-MultipleBuildingTypes-fields-new) +### [Additional fields](@id nodes-AbstractBuildings-fields-new) -[`MultipleBuildingTypes`](@ref) nodes introduce additional fields for demand and penalty specifications: +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes introduce additional fields for demand and penalty specifications: - **`cap::Dict{<:Resource,<:TimeProfile}`**:\ The demand capacity for each of the input resources. @@ -53,7 +68,7 @@ Standard fields of a [`MultipleBuildingTypes`](@ref) node are given as: These penalties are added to the variable operating expenses. All values have to be non-negative. -## [Mathematical description](@id nodes-MultipleBuildingTypes-math) +## [Mathematical description](@id nodes-AbstractBuildings-math) In the following mathematical equations, we use the name for variables and functions used in the model. Variables are in general represented as @@ -64,44 +79,44 @@ with square brackets, while functions are represented as ``func\_example(index_1, index_2)`` -with parantheses. +with parentheses. -### [Variables](@id nodes-MultipleBuildingTypes-math-var) +### [Variables](@id nodes-AbstractBuildings-math-var) -#### [Standard variables](@id nodes-MultipleBuildingTypes-math-var-stand) +#### [Standard variables](@id nodes-AbstractBuildings-math-var-stand) -The [`MultipleBuildingTypes`](@ref) node type utilizes standard variables from the [`Sink`](@extref EnergyModelsBase.Sink) node type and includes: +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes utilize standard variables from the [`Sink`](@extref EnergyModelsBase.Sink) node type and includes: - [``\texttt{opex\_var}``](@extref EnergyModelsBase man-opt_var-opex) - [``\texttt{opex\_fixed}``](@extref EnergyModelsBase man-opt_var-opex) - [``\texttt{flow\_in}``](@extref EnergyModelsBase man-opt_var-flow) - [``\texttt{sink\_surplus}``](@extref EnergyModelsBase man-opt_var-sink): Declared as the total surplus aggregated across all resources. -- [``\texttt{sink\_deficit}``](@extref EnergyModelsBase man-opt_var-sink): Declared as the total surplus aggregated across all resources. +- [``\texttt{sink\_deficit}``](@extref EnergyModelsBase man-opt_var-sink): Declared as the total deficit aggregated across all resources. - [``\texttt{emissions\_node}``](@extref EnergyModelsBase man-opt_var-emissions) if `EmissionsData` is added to the field `data` !!! note "cap\_use and cap\_inst" - A `MultipleBuildingTypes` has an individual capacity for all its resources, that is each `Resource` has its own capacity which must be satisfied. - As a consequence, the standard variables [``\texttt{cap\_use}``](@extref EnergyModelsBase man-opt_var-cap) and [``\texttt{cap\_use}``](@extref EnergyModelsBase man-opt_var-cap) are not defined for [`MultipleBuildingTypes`](@ref) nodes through a new method for the function [`has_capacity`](@ref EnergyModelsBase.capacity). + [AbstractBuildings](@ref EMLI.AbstractBuildings) nodes have an individual capacity for all their resources, that is each `Resource` has its own capacity which must be satisfied. + As a consequence, the standard variables [``\texttt{cap\_use}``](@extref EnergyModelsBase man-opt_var-cap) and [``\texttt{cap\_inst}``](@extref EnergyModelsBase man-opt_var-cap) are not defined for [AbstractBuildings](@ref EMLI.AbstractBuildings) nodes through a new method for the function [`has_capacity`](@ref EnergyModelsBase.capacity). -#### [Additional variables](@id nodes-MultipleBuildingTypes-math-add) +#### [Additional variables](@id nodes-AbstractBuildings-math-add) -[`MultipleBuildingTypes`](@ref) introduces the following variables: +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes introduce the following variables: - ``\texttt{buildings\_surplus}[n, t, p]``: Surplus (over-supply) for node ``n`` in operational period ``t`` for resource ``p``. - ``\texttt{buildings\_deficit}[n, t, p]``: Deficit (under-supply) for node ``n`` in operational period ``t`` for resource ``p``. - ``\texttt{sink\_surplus}[n, t]``: Total surplus aggregated across all resources. - ``\texttt{sink\_deficit}[n, t]``: Total deficit aggregated across all resources. -### [Constraints](@id nodes-MultipleBuildingTypes-math-con) +### [Constraints](@id nodes-AbstractBuildings-math-con) -The following sections omit the direct inclusion of the vector of [`MultipleBuildingTypes`](@ref) nodes. -Instead, it is implicitly assumed that the constraints are valid ``\forall n โˆˆ N^{\text{MultipleBuildingTypes}}`` if not stated differently. +The following sections omit the direct inclusion of the vector of [AbstractBuildings](@ref EMLI.AbstractBuildings) nodes. +Instead, it is implicitly assumed that the constraints are valid ``\forall n โˆˆ N^{\text{AbstractBuildings}}`` 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). Finally, all constraints are valid ``\forall p \in inputs(n)`` (that is in all input resources). -#### [Standard constraints](@id nodes-MultipleBuildingTypes-math-con-stand) +#### [Standard constraints](@id nodes-AbstractBuildings-math-con-stand) -[`MultipleBuildingTypes`](@ref) nodes utilize the following standard constraint functions: +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes utilize the following standard constraint functions: - `constraints_opex_fixed`:\ The current implementation fixes the fixed operating expenses of a sink to 0. @@ -149,6 +164,6 @@ The function `constraints_opex_var` is extended with a new method to allow for i It also takes into account potential operational scenarios and their probability as well as representative periods. -#### [Additional constraints](@id nodes-MultipleBuildingTypes-math-con-add) +#### [Additional constraints](@id nodes-AbstractBuildings-math-con-add) -[`MultipleBuildingTypes`](@ref) nodes do not add additional constraints. +[AbstractBuildings](@ref EMLI.AbstractBuildings) nodes do not add additional constraints. diff --git a/docs/src/nodes/pv.md b/docs/src/nodes/pv.md index 4c4863a..ed73ff2 100644 --- a/docs/src/nodes/pv.md +++ b/docs/src/nodes/pv.md @@ -35,7 +35,7 @@ The standard fields (of a [`AbstractNonDisRES`](@extref EnergyModelsRenewablePro 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}`**:\ +- **`data::Vector{<:ExtensionData}`**:\ 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. diff --git a/docs/src/nodes/pvandcsp.md b/docs/src/nodes/pvandcsp.md index aaaef6c..6ce026c 100644 --- a/docs/src/nodes/pvandcsp.md +++ b/docs/src/nodes/pvandcsp.md @@ -27,7 +27,7 @@ Standard fields (of an [`AbstractNonDisRES`](@extref EnergyModelsRenewableProduc The field `output` includes [`Resource`](@extref EnergyModelsBase.Resource)s with their corresponding conversion factors as dictionaries. In the case of a PV and CSP energy source, `output` should always include your *electricity* resource and a *heat* resource. In practice, you should use a value of 1.\ All values have to be non-negative. -- **`data::Vector{Data}`**:\ +- **`data::Vector{<:ExtensionData}`**:\ An entry for providing additional data to the model. In the current version, it is not applicable. We intend to change this in future releases to enable investments. diff --git a/docs/src/nodes/windpower.md b/docs/src/nodes/windpower.md index 2b28583..578b9c1 100644 --- a/docs/src/nodes/windpower.md +++ b/docs/src/nodes/windpower.md @@ -38,7 +38,7 @@ The standard fields (of a [`AbstractNonDisRES`](@extref EnergyModelsRenewablePro 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}`**:\ +- **`data::Vector{<:ExtensionData}`**:\ 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. diff --git a/ext/EMGUIExt/descriptive_names.yml b/ext/EMGUIExt/descriptive_names.yml index c1ecf09..096cb71 100644 --- a/ext/EMGUIExt/descriptive_names.yml +++ b/ext/EMGUIExt/descriptive_names.yml @@ -3,5 +3,8 @@ structures: EnergyModelsLanguageInterfaces: MultipleBuildingTypes: + penalty_surplus: "Penalty for surplus" + penalty_deficit: "Penalty for deficits" + Building: penalty_surplus: "Penalty for surplus" penalty_deficit: "Penalty for deficits" \ No newline at end of file diff --git a/ext/EMIExt/EMIExt.jl b/ext/EMIExt/EMIExt.jl index 4a5d8d5..58162f3 100644 --- a/ext/EMIExt/EMIExt.jl +++ b/ext/EMIExt/EMIExt.jl @@ -21,7 +21,7 @@ const EMLI = EnergyModelsLanguageInterfaces mass_fractions::Dict{<:ResourceBio,<:Real}, heat_output_ratios::Dict{<:ResourceHeat,<:Real}, electricity_resource::Resource; - data::Vector{Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], libpath::String = joinpath( @__DIR__, "..", @@ -50,7 +50,7 @@ library file located at `libpath`. The BioCHP has electricity production of the - **`electricity_resource`** is the `Resource` for the electricity. # Keyword arguments -- **`data::Vector{Data}`** is the additional data (*e.g.*, for investments). The field `data` +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The field `data` is conditional through usage of a constructor. - **`libpath`** is the absolute path of the `CHP_modelling` library file. @@ -65,7 +65,7 @@ function EMLI.BioCHP( mass_fractions::Dict{<:ResourceBio,<:Real}, heat_output_ratios::Dict{<:ResourceHeat,<:Real}, electricity_resource::Resource; - data::Vector{Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], libpath::String = joinpath( @__DIR__, "..", diff --git a/src/EnergyModelsLanguageInterfaces.jl b/src/EnergyModelsLanguageInterfaces.jl index 6816fb6..eded97f 100644 --- a/src/EnergyModelsLanguageInterfaces.jl +++ b/src/EnergyModelsLanguageInterfaces.jl @@ -37,5 +37,6 @@ export call_python_function, fetch_element export WindPower, CSPandPV, MultipleBuildingTypes export ResourceBio, BioCHP export PV, PVParameters +export Building end # module EnergyModelsLanguageInterfaces diff --git a/src/checks.jl b/src/checks.jl index b7ec67d..e719f06 100644 --- a/src/checks.jl +++ b/src/checks.jl @@ -35,9 +35,9 @@ function EMB.check_node( end """ - EMB.check_node(n::MultipleBuildingTypes, ๐’ฏ, ::EnergyModel, ::Bool) + EMB.check_node(n::AbstractBuildings, ๐’ฏ, ::EnergyModel, ::Bool) -This method checks that the [`MultipleBuildingTypes`](@ref) node is valid. +This method checks that the [`AbstractBuildings`](@ref) node is valid. ## Checks - The field `cap_p` is required to be non-negative for all resources `p`. @@ -45,7 +45,7 @@ This method checks that the [`MultipleBuildingTypes`](@ref) node is valid. - The sum of the fields `penalty_surplus` and `penalty_deficit` has to be non-negative to avoid an infeasible model. """ -function EMB.check_node(n::MultipleBuildingTypes, ๐’ฏ, ::EnergyModel, ::Bool) +function EMB.check_node(n::AbstractBuildings, ๐’ฏ, ::EnergyModel, ::Bool) ๐’ซ = inputs(n) for p โˆˆ ๐’ซ @assert_or_log( diff --git a/src/constraint_functions.jl b/src/constraint_functions.jl index 2e0cdb0..1c6058f 100644 --- a/src/constraint_functions.jl +++ b/src/constraint_functions.jl @@ -1,12 +1,12 @@ """ - EMB.constraints_capacity(m, n::MultipleBuildingTypes, ๐’ฏ::TimeStructure, modeltype::EnergyModel) + EMB.constraints_capacity(m, n::AbstractBuildings, ๐’ฏ::TimeStructure, modeltype::EnergyModel) Function for creating the constraints on the maximum capacity of a -[`MultipleBuildingTypes`](@ref) node. +[`AbstractBuildings`](@ref) node. """ function EMB.constraints_capacity( m, - n::MultipleBuildingTypes, + n::AbstractBuildings, ๐’ฏ::TimeStructure, ::EnergyModel, ) @@ -57,12 +57,12 @@ function EMB.constraints_capacity(m, n::CSPandPV, ๐’ฏ::TimeStructure, ::EnergyM end """ - EMB.constraints_flow_in(m, n::MultipleBuildingTypes, ๐’ฏ::TimeStructure, ::EnergyModel) + EMB.constraints_flow_in(m, n::AbstractBuildings, ๐’ฏ::TimeStructure, ::EnergyModel) -The constraints on the inlet flow for a [`MultipleBuildingTypes`](@ref) node are implemented +The constraints on the inlet flow for a [`AbstractBuildings`](@ref) node are implemented directly in the function `EMB.constraints_capacity`. """ -function EMB.constraints_flow_in(m, ::MultipleBuildingTypes, ::TimeStructure, ::EnergyModel) +function EMB.constraints_flow_in(m, ::AbstractBuildings, ::TimeStructure, ::EnergyModel) end """ @@ -81,15 +81,15 @@ function EMB.constraints_flow_out(m, n::CSPandPV, ๐’ฏ::TimeStructure, modeltype end """ - EMB.constraints_opex_var(m, n::MultipleBuildingTypes, ๐’ฏแดตโฟแต›, ::EnergyModel) + EMB.constraints_opex_var(m, n::AbstractBuildings, ๐’ฏแดตโฟแต›, ::EnergyModel) -Function for creating the constraint on the variable OPEX of a [`MultipleBuildingTypes`](@ref) +Function for creating the constraint on the variable OPEX of a [`AbstractBuildings`](@ref) node. The variable OPEX is calculate through the penalties for both `surplus` and `deficit` for each of the individual resource demands. """ -function EMB.constraints_opex_var(m, n::MultipleBuildingTypes, ๐’ฏแดตโฟแต›, ::EnergyModel) +function EMB.constraints_opex_var(m, n::AbstractBuildings, ๐’ฏแดตโฟแต›, ::EnergyModel) @constraint(m, [t_inv โˆˆ ๐’ฏแดตโฟแต›], m[:opex_var][n, t_inv] == sum( diff --git a/src/datastructures.jl b/src/datastructures.jl index 561fcaa..90caa50 100644 --- a/src/datastructures.jl +++ b/src/datastructures.jl @@ -93,7 +93,7 @@ sampling the profile from a Python code through a constructor. - **`opex_var::TimeProfile`** is the variable operating expense per energy unit produced. - **`opex_fixed::TimeProfile`** is the fixed operating expense. - **`output::Dict{Resource, Real}`** are the generated `Resource`s, normally Power. -- **`data::Vector{<:Data}`** is the additional data (e.g. for investments). The field `data` +- **`data::Vector{<:ExtensionData}`** is the additional data (e.g. for investments). The field `data` is conditional through usage of a constructor. """ struct WindPower <: AbstractNonDisRES @@ -103,7 +103,7 @@ struct WindPower <: AbstractNonDisRES opex_var::TimeProfile opex_fixed::TimeProfile output::Dict{<:Resource,<:Real} - data::Vector{<:Data} + data::Vector{<:ExtensionData} end function WindPower( id::Any, @@ -113,7 +113,7 @@ function WindPower( opex_fixed::TimeProfile, output::Dict{<:Resource,<:Real}, ) - return WindPower(id, cap, profile, opex_var, opex_fixed, output, Data[]) + return WindPower(id, cap, profile, opex_var, opex_fixed, output, ExtensionData[]) end """ @@ -126,7 +126,7 @@ end opex_var::TimeProfile, opex_fixed::TimeProfile, output::Dict{<:Resource,<:Real}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], method::String = "Ninja", data_path::String = "", source::String = "NORA3", @@ -182,7 +182,7 @@ function WindPower( opex_var::TimeProfile, opex_fixed::TimeProfile, output::Dict{<:Resource,<:Real}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], method::String = "Ninja", data_path::String = "", source::String = "NORA3", @@ -217,7 +217,7 @@ through a constructor. - **`opex_var::TimeProfile`** is the variable operating expense per energy unit produced. - **`opex_fixed::TimeProfile`** is the fixed operating expense. - **`output::Dict{Resource, Real}`** are the generated `Resource`s, normally Power. -- **`data::Vector{<:Data}`** is the additional data (e.g. for investments). The field `data` +- **`data::Vector{<:ExtensionData}`** is the additional data (e.g. for investments). The field `data` is conditional through usage of a constructor. """ struct PV <: AbstractNonDisRES @@ -227,7 +227,7 @@ struct PV <: AbstractNonDisRES opex_var::TimeProfile opex_fixed::TimeProfile output::Dict{<:Resource,<:Real} - data::Vector{<:Data} + data::Vector{<:ExtensionData} end function PV( id::Any, @@ -237,7 +237,7 @@ function PV( opex_fixed::TimeProfile, output::Dict{<:Resource,<:Real}, ) - return PV(id, cap, profile, opex_var, opex_fixed, output, Data[]) + return PV(id, cap, profile, opex_var, opex_fixed, output, ExtensionData[]) end """ @@ -250,28 +250,28 @@ end time_start::DateTime, time_end::DateTime, params::PVParameters; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_path::String = "pvgis_cache", filename_hint::String = "", ) -Constructs a [`PV`](@ref) instance where the power production profile is sampled from -the PVGIS API. - -# Arguments -- **`id`**: The name or identifier of the node. -- **`cap`**: The installed capacity. -- **`opex_var`**: The variable operating expense per energy unit produced. -- **`opex_fixed`**: The fixed operating expense. -- **`output`**: The generated `Resource`s, normally Power, with conversion value `Real`. -- **`time_start::DateTime`**: The start of the time range for which the PV output data is requested. -- **`time_end::DateTime`**: The end of the time range for which the PV output data is requested. -- **`params::PVParameters`**: Parameters for the PV system. See [`PVParameters`](@ref) for details. +A PV source producing power. It extends the existing `AbstractNonDisRES` node by extracting +data from the PVGIS tool from the EU Science Hub (available at https://re.jrc.ec.europa.eu/pvg_tools) +through a constructor. -# Keyword arguments -- **`data`**: Additional data (e.g., for investments). Default is no `data`. -- **`data_path`**: Directory where the cached CSV file will be stored. Default is `"pvgis_cache"`. -- **`filename_hint`**: Optional string to include in the cache file name for identification. Default is `""`. +# Fields +- **`id`** is the name/identifier of the node. +- **`cap::TimeProfile`** is the installed capacity. +- **`opex_var::TimeProfile`** is the variable operating expense per energy unit produced. +- **`opex_fixed::TimeProfile`** is the fixed operating expense. +- **`output::Dict{<:Resource,<:Real}`** are the generated `Resource`s, normally Power. +- **`time_start::DateTime`** is the start of the time range for which the PV output data is requested. +- **`time_end::DateTime`** is the end of the time range for which the PV output data is requested. +- **`params::PVParameters`** are the parameters for the PV system. See [`PVParameters`](@ref) for details. +- **`data::Vector{<:ExtensionData}`** is the additional data (e.g., for investments). + The field `data` is conditional through usage of a constructor. +- **`data_path::String`** is the directory where the cached CSV file will be stored. Default is `"pvgis_cache"`. +- **`filename_hint::String`** is an optional string to include in the cache file name for identification. Default is `""`. """ function PV( id::Any, @@ -282,7 +282,7 @@ function PV( time_start::DateTime, time_end::DateTime, params::PVParameters; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_path::String = "pvgis_cache", filename_hint::String = "", ) @@ -313,7 +313,7 @@ the strategic level. - **`opex_fixed::Dict{<:Resource,<:TimeProfile}`** is the fixed operating expense (for all resources in a Dict). - **`output::Dict{Resource, Real}`** are the generated `Resource`s, normally Power. -- **`data::Vector{<:Data}`** is the additional data (e.g. for investments). The field `data` +- **`data::Vector{<:ExtensionData}`** is the additional data (e.g. for investments). The field `data` is conditional through usage of a constructor. !!! danger @@ -326,7 +326,7 @@ struct CSPandPV <: AbstractNonDisRES opex_var::Dict{<:Resource,<:TimeProfile} opex_fixed::Dict{<:Resource,<:TimeProfile} output::Dict{<:Resource,<:Real} - data::Vector{<:Data} + data::Vector{<:ExtensionData} end function CSPandPV( id::Any, @@ -336,7 +336,7 @@ function CSPandPV( opex_fixed::Dict{<:Resource,<:TimeProfile}, output::Dict{<:Resource,<:Real}, ) - return CSPandPV(id, cap, profile, opex_var, opex_fixed, output, Data[]) + return CSPandPV(id, cap, profile, opex_var, opex_fixed, output, ExtensionData[]) end """ @@ -346,7 +346,7 @@ end time_start::DateTime, time_end::DateTime, resources_map::Dict{String,<:Resource}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_location::String = joinpath(tempdir(), "CSPandPV"), overwrite_saved_data::Bool = false, ) @@ -396,7 +396,7 @@ function CSPandPV( time_start::DateTime, time_end::DateTime, resources_map::Dict{String,<:Resource}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_location::String = joinpath(tempdir(), "CSPandPV"), overwrite_saved_data::Bool = false, ) @@ -517,7 +517,140 @@ EMR.profile(n::CSPandPV, p::Resource) = n.profile[p] EMR.profile(n::CSPandPV, t, p::Resource) = n.profile[p][t] """ - struct MultipleBuildingTypes <: EMB.Sink + abstract type AbstractBuildings <: EMB.Sink + +Abstract supertype for all building nodes. +""" +abstract type AbstractBuildings <: EMB.Sink end + +""" + struct Building <: AbstractBuildings + +A [`Building`](@ref) node representing a single-location building heat-demand-from-temperature +constructor. It creates sinks for all demand resources, where the demand for each resource +can have separate penalties for surplus and deficit. +The penalties introduced in the fields `penalty_surplus` and `penalty_deficit` affect the +variable OPEX for surplus and deficit, respectively. + +# Fields +- **`id`** is the name/identifier of the node. +- **`cap::Dict{<:Resource,<:TimeProfile}`** is the demand. +- **`penalty_surplus::Dict{<:Resource,<:TimeProfile}`** are the penalties for surplus. +- **`penalty_deficit::Dict{<:Resource,<:TimeProfile}`** are the penalties for deficit. +- **`input::Dict{<:Resource,<:Real}`** are the input + [`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. + +!!! danger + Investments are not available for this node. +""" +struct Building <: AbstractBuildings + id::Any + cap::Dict{<:Resource,<:TimeProfile} + penalty_surplus::Dict{<:Resource,<:TimeProfile} + penalty_deficit::Dict{<:Resource,<:TimeProfile} + input::Dict{<:Resource,<:Real} + data::Vector{<:ExtensionData} +end +function Building( + id::Any, + cap::Dict{<:Resource,<:TimeProfile}, + penalty_surplus::Dict{<:Resource,<:TimeProfile}, + penalty_deficit::Dict{<:Resource,<:TimeProfile}, + input::Dict{<:Resource,<:Real}, +) + return Building(id, cap, penalty_surplus, penalty_deficit, input, ExtensionData[]) +end + +""" + Building( + id::Any, + cap::Dict{<:Resource,<:TimeProfile}, + penalty_surplus::Dict{<:Resource,<:TimeProfile}, + penalty_deficit::Dict{<:Resource,<:TimeProfile}, + input::Dict{<:Resource,<:Real}, + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + heat_resource::Resource, + temp_to_demand::Function; + data::Vector{<:ExtensionData} = ExtensionData[], + data_path::String = joinpath(tempdir(), "building"), + source::String = "NORA3", + reload_csv::Bool = true, + save_csv::Bool = true, + ) + +Constructs a [`Building`](@ref) instance where the heat demand profile is generated from temperature data +downloaded using hindcast data (see [`heat_demand_profile`](@ref) for details). +The temperature-to-demand mapping is provided by `temp_to_demand`. + +# Arguments +- **`id`** is the name/identifier of the node. +- **`cap::Dict{<:Resource,<:TimeProfile}`** is the demand (no need to provide heat demand, it will be generated). +- **`penalty_surplus::Dict{<:Resource,<:TimeProfile}`** are the penalties for surplus. +- **`penalty_deficit::Dict{<:Resource,<:TimeProfile}`** are the penalties for deficit. +- **`input::Dict{<:Resource,<:Real}`** are the input [`Resource`](@extref EnergyModelsBase.Resource)s + with conversion value `Real`. +- **`time_start::DateTime`** is the start time for the demand profile. +- **`time_end::DateTime`** is the end time for the demand profile. +- **`lat::Real`** is the latitude of the building location. +- **`lon::Real`** is the longitude of the building location. +- **`heat_resource::Resource`** is the `Resource` object representing heat demand in the model. +- **`temp_to_demand::Function`** is the function mapping temperature in Kelvin to demand. + +# Keyword arguments +- **`data::Vector{<:ExtensionData}`** is the additional data to be used. +- **`data_path::String`** is the directory path for cached CSV files. +- **`source::String`** is the data source, e.g., "NORA3" or "ERA5". +- **`reload_csv::Bool`** is a boolean flag to reload data from local CSV files if available (default: true). +- **`save_csv::Bool`** is a boolean flag to save data to CSV files. +""" +function Building( + id::Any, + cap::Dict{<:Resource,<:TimeProfile}, + penalty_surplus::Dict{<:Resource,<:TimeProfile}, + penalty_deficit::Dict{<:Resource,<:TimeProfile}, + input::Dict{<:Resource,<:Real}, + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + heat_resource::Resource, + temp_to_demand::Function; + data::Vector{<:ExtensionData} = ExtensionData[], + data_path::String = joinpath(tempdir(), "building"), + source::String = "NORA3", + reload_csv::Bool = true, + save_csv::Bool = true, +) + df = heat_demand_profile( + time_start, + time_end, + lat, + lon, + temp_to_demand; + data_path = data_path, + source = source, + reload_csv = reload_csv, + save_csv = save_csv, + ) + if heat_resource โˆˆ keys(cap) + @warn "The provided capacity dictionary already contains a profile for the `heat_resource`. " * + "The generated heat demand profile will overwrite the existing profile." + end + + # Copy to ensure we don't modify the original `cap` dictionary and widen key/value types + cap_ext = Dict{Resource,TimeProfile}(resource => profile for (resource, profile) โˆˆ cap) + cap_ext[heat_resource] = OperationalProfile(df.heat_demand) + + return Building(id, cap_ext, penalty_surplus, penalty_deficit, input, data) +end + +""" + struct MultipleBuildingTypes <: AbstractBuildings A [`MultipleBuildingTypes`](@ref) node that creates sinks for all demand resources. The demand for each resources has a penalty for both surplus and deficit. @@ -531,19 +664,19 @@ and deficit. - **`penalty_deficit::Dict{<:Resource,<:TimeProfile}`** are the penalties for deficit. - **`input::Dict{<:Resource,<:Real}`** are the input [`Resource`](@extref EnergyModelsBase.Resource)s with conversion value `Real`. -- **`data::Vector{<:Data}`** is the additional data (*e.g.*, for investments). The field `data` +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The field `data` is conditional through usage of a constructor. !!! danger Investments are not available for this node. """ -struct MultipleBuildingTypes <: EMB.Sink +struct MultipleBuildingTypes <: AbstractBuildings id::Any cap::Dict{<:Resource,<:TimeProfile} penalty_surplus::Dict{<:Resource,<:TimeProfile} penalty_deficit::Dict{<:Resource,<:TimeProfile} input::Dict{<:Resource,<:Real} - data::Vector{<:Data} + data::Vector{<:ExtensionData} end function MultipleBuildingTypes( id::Any, @@ -552,7 +685,14 @@ function MultipleBuildingTypes( penalty_deficit::Dict{<:Resource,<:TimeProfile}, input::Dict{<:Resource,<:Real}, ) - return MultipleBuildingTypes(id, cap, penalty_surplus, penalty_deficit, input, Data[]) + return MultipleBuildingTypes( + id, + cap, + penalty_surplus, + penalty_deficit, + input, + ExtensionData[], + ) end """ @@ -566,7 +706,7 @@ end T::TimeStructure, penalty_surplus::Dict{<:Resource,<:TimeProfile}, penalty_deficit::Dict{<:Resource,<:TimeProfile}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_location::String = joinpath(tempdir(), "buildings"), overwrite_saved_data::Bool = false, ) @@ -619,15 +759,15 @@ Constructs a `MultipleBuildingTypes` instance where the demand profiles are samp "Heat|Solar" => SolarHeat, ) ``` -- **`T`** is the TimeStructure used in the model. +- **`T::TimeStructure`** is the TimeStructure used in the model. - **`penalty_surplus::Dict{<:Resource,<:TimeProfile}`** is the penalties for surplus. - **`penalty_deficit::Dict{<:Resource,<:TimeProfile}`** is the penalties for deficit. # Keyword arguments -- **`data`** is the additional data (*e.g.*, for investments). The default value is no `data`. -- **`data_location`** is the location where the data is saved. The default value is in the +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The default value is no `data`. +- **`data_location::String`** is the location where the data is saved. The default value is in the temporary directory. -- **`overwrite_saved_data`** is a boolean that determines if the stored data should be +- **`overwrite_saved_data::Bool`** is a boolean that determines if the stored data should be overwritten (in which case the building_energy_process is called). The default value is `false`. @@ -659,7 +799,7 @@ function MultipleBuildingTypes( T::TimeStructure, penalty_surplus::Dict{<:Resource,<:TimeProfile}, penalty_deficit::Dict{<:Resource,<:TimeProfile}; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], data_location::String = joinpath(tempdir(), "buildings"), overwrite_saved_data::Bool = false, ) @@ -721,47 +861,47 @@ function MultipleBuildingTypes( end """ - EMB.capacity(n::MultipleBuildingTypes) - EMB.capacity(n::MultipleBuildingTypes, p::Resource) - EMB.capacity(n::MultipleBuildingTypes, t, p::Resource) + EMB.capacity(n::AbstractBuildings) + EMB.capacity(n::AbstractBuildings, p::Resource) + EMB.capacity(n::AbstractBuildings, t, p::Resource) -Returns the capacity of a MultipleBuildingTypes `n` as a `Dictionary` or of resource `p` as `TimeProfile` +Returns the capacity of an AbstractBuildings `n` as a `Dictionary` or of resource `p` as `TimeProfile` or in operational period `t`. """ -EMB.capacity(n::MultipleBuildingTypes) = n.cap -EMB.capacity(n::MultipleBuildingTypes, p::Resource) = n.cap[p] -EMB.capacity(n::MultipleBuildingTypes, t, p::Resource) = n.cap[p][t] +EMB.capacity(n::AbstractBuildings) = n.cap +EMB.capacity(n::AbstractBuildings, p::Resource) = n.cap[p] +EMB.capacity(n::AbstractBuildings, t, p::Resource) = n.cap[p][t] """ - EMB.has_capacity(n::MultipleBuildingTypes) + EMB.has_capacity(n::AbstractBuildings) -A MultipleBuildingTypes has capacity for all its resources but not in a EMB sense. +An AbstractBuildings has capacity for all its resources but not in a EMB sense. """ -EMB.has_capacity(n::MultipleBuildingTypes) = false +EMB.has_capacity(n::AbstractBuildings) = false """ - EMB.surplus_penalty(n::MultipleBuildingTypes) - EMB.surplus_penalty(n::MultipleBuildingTypes, p::Resource) - EMB.surplus_penalty(n::MultipleBuildingTypes, t, p::Resource) + EMB.surplus_penalty(n::AbstractBuildings) + EMB.surplus_penalty(n::AbstractBuildings, p::Resource) + EMB.surplus_penalty(n::AbstractBuildings, t, p::Resource) -Returns the surplus penalty of MultipleBuildingTypes `n` as a `Dictionary` or of resource `p` as `TimeProfile` +Returns the surplus penalty of AbstractBuildings `n` as a `Dictionary` or of resource `p` as `TimeProfile` or in operational period `t`. """ -EMB.surplus_penalty(n::MultipleBuildingTypes) = n.penalty_surplus -EMB.surplus_penalty(n::MultipleBuildingTypes, p::Resource) = n.penalty_surplus[p] -EMB.surplus_penalty(n::MultipleBuildingTypes, t, p::Resource) = n.penalty_surplus[p][t] +EMB.surplus_penalty(n::AbstractBuildings) = n.penalty_surplus +EMB.surplus_penalty(n::AbstractBuildings, p::Resource) = n.penalty_surplus[p] +EMB.surplus_penalty(n::AbstractBuildings, t, p::Resource) = n.penalty_surplus[p][t] """ - EMB.deficit_penalty(n::MultipleBuildingTypes) - EMB.deficit_penalty(n::MultipleBuildingTypes, p::Resource) - EMB.deficit_penalty(n::MultipleBuildingTypes, t, p::Resource) + EMB.deficit_penalty(n::AbstractBuildings) + EMB.deficit_penalty(n::AbstractBuildings, p::Resource) + EMB.deficit_penalty(n::AbstractBuildings, t, p::Resource) -Returns the deficit penalty of MultipleBuildingTypes `n` as a `Dictionary` or of resource `p` as `TimeProfile` +Returns the deficit penalty of AbstractBuildings `n` as a `Dictionary` or of resource `p` as `TimeProfile` or in operational period `t`. """ -EMB.deficit_penalty(n::MultipleBuildingTypes) = n.penalty_deficit -EMB.deficit_penalty(n::MultipleBuildingTypes, p::Resource) = n.penalty_deficit[p] -EMB.deficit_penalty(n::MultipleBuildingTypes, t, p::Resource) = n.penalty_deficit[p][t] +EMB.deficit_penalty(n::AbstractBuildings) = n.penalty_deficit +EMB.deficit_penalty(n::AbstractBuildings, p::Resource) = n.penalty_deficit[p] +EMB.deficit_penalty(n::AbstractBuildings, t, p::Resource) = n.penalty_deficit[p][t] """ ResourceBio{T<:Real} <: Resource @@ -825,7 +965,7 @@ The capacity is hereby normalized to a conversion value of 1 in the fields `inpu 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` +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). The field `data` is conditional through usage of a constructor. """ struct BioCHP <: NetworkNode @@ -836,7 +976,7 @@ struct BioCHP <: NetworkNode opex_fixed::TimeProfile input::Dict{<:ResourceBio,<:Real} output::Dict{<:Resource,<:Real} - data::Vector{<:Data} + data::Vector{<:ExtensionData} end function BioCHP( id, @@ -866,7 +1006,7 @@ end mass_fractions::Dict{<:ResourceBio,<:Real}, heat_output_ratios::Dict{<:ResourceHeat,<:Real}, electricity_resource::Resource; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], libpath::String = joinpath( @__DIR__, "..", @@ -893,7 +1033,7 @@ library file located at `libpath`. The BioCHP has electricity production of the - **`electricity_resource`** is the `Resource` for the electricity. # Keyword arguments -- **`data::Vector{<:Data}`** is the additional data (*e.g.*, for investments). +- **`data::Vector{<:ExtensionData}`** is the additional data (*e.g.*, for investments). - **`libpath`** is the absolute path of the `CHP_modelling` library file. !!! note "EmissionsEnergy" @@ -908,7 +1048,7 @@ function BioCHP( mass_fractions::Dict{<:ResourceBio,<:Real}, heat_output_ratios::Dict{<:ResourceHeat,<:Real}, electricity_resource::Resource; - data::Vector{<:Data} = Data[], + data::Vector{<:ExtensionData} = ExtensionData[], libpath::String = joinpath( @__DIR__, "..", @@ -944,7 +1084,7 @@ function BioCHP( mass_fractions::Dict{<:ResourceBio,<:Real}, heat_output_ratios::Dict{<:ResourceHeat,<:Real}, electricity_resource::Resource, - data::Vector{<:Data}, + data::Vector{<:ExtensionData}, libpath::String, ) # Get the capacity diff --git a/src/model.jl b/src/model.jl index c201388..4075b5f 100644 --- a/src/model.jl +++ b/src/model.jl @@ -1,13 +1,13 @@ """ - EMB.variables_node(m, ๐’ฉ::Vector{MultipleBuildingTypes}, ๐’ฏ, ::EnergyModel) + EMB.variables_node(m, ๐’ฉ::Vector{AbstractBuildings}, ๐’ฏ, ::EnergyModel) -For a [`MultipleBuildingTypes`](@ref) node, the following variables are created: +For a [`AbstractBuildings`](@ref) node, the following variables are created: - `buildings_surplus[n, t, p]` is the surplus of node `n` with resource `p` in operational period `t`. -- `buildings_deficit[n, t, p]` is the surplus of node `n` with resource `p` in operational +- `buildings_deficit[n, t, p]` is the deficit of node `n` with resource `p` in operational period `t`. """ -function EMB.variables_node(m, ๐’ฉ::Vector{MultipleBuildingTypes}, ๐’ฏ, ::EnergyModel) +function EMB.variables_node(m, ๐’ฉ::Vector{AbstractBuildings}, ๐’ฏ, ::EnergyModel) # Declaration of the required subsets. ๐’ซ = unique([p for n โˆˆ ๐’ฉ for p โˆˆ inputs(n)]) diff --git a/src/utils.jl b/src/utils.jl index 580d081..74c7fe8 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -59,7 +59,7 @@ function get_python_function(module_name::String, function_name::String) sub_names = split(function_name, ".") python_function = pyimport(module_name) for name โˆˆ sub_names - python_function = python_function[name] + python_function = getproperty(python_function, Symbol(name)) end return python_function end @@ -96,6 +96,15 @@ function fetch_element(elements, id) return getfirst(element -> element.id == id, elements) end +function sanitize_filename_hint(filename_hint::String) + if isempty(filename_hint) + filehint = "" + else + filehint = "_" * replace(filename_hint, r"[^\w\.-]" => "_") + end + return filehint +end + """ pvgis_profile(time_start::DateTime, params::PVParameters; peakpower::Real=1.0, @@ -156,11 +165,7 @@ function pvgis_profile(time_start::DateTime, params::PVParameters; end # Create a sanitized file hint for the cache file name - if isempty(filename_hint) - filehint = "" - else - filehint = "_" * replace(filename_hint, r"[^\w\.-]" => "_") - end + filehint = sanitize_filename_hint(filename_hint) csv_path = joinpath( data_path, @@ -327,3 +332,178 @@ function get_pvgis_data( # WS10m [m/s] - Wind speed at 10m return DataFrame(rows) end + +""" + heat_demand_profile( + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + temp_to_demand::Function; + data_path::String = "metocean_api_data", + filename_hint::String = "", + source::String = "NORA3", + reload_csv::Bool = true, + save_csv::Bool = true, + ) + +Generates a heat demand profile for a specified location and time period using temperature data +and a user-provided temperature-to-demand mapping function. + +The function retrieves temperature data for the given latitude and longitude from the specified +data source (e.g., "NORA3" or "ERA5") and applies the `temp_to_demand` function to convert +temperature values into heat demand. + +The data source is queried using the [`get_met_data`](@ref) function, which handles data retrieval, +caching, and storage. + +# Arguments +- **`time_start::DateTime`** is the start of the time period for the demand profile. +- **`time_end::DateTime`** is the end of the time period for the demand profile. +- **`lat::Real`** is the latitude of the location. +- **`lon::Real`** is the longitude of the location. +- **`temp_to_demand::Function`** is a function mapping temperature in Kelvin to demand. +- **`data_path::String`** is the directory path to store or load temperature data (default: "metocean_api_data"). +- **`filename_hint::String`** is an optional hint for naming the data file (default: ""). +- **`source::String`** is the data source for temperature (default: "NORA3"). +- **`reload_csv::Bool`** is a flag indicating whether to reload CSV data if available (default: true). +- **`save_csv::Bool`** is a flag indicating whether to save the generated profile to a CSV file (default: true). +""" +function heat_demand_profile( + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + temp_to_demand::Function; + data_path::String = "metocean_api_data", + filename_hint::String = "", + source::String = "NORA3", + reload_csv::Bool = true, + save_csv::Bool = true, +) + if source == "NORA3" + product = "NORA3_atm_sub" + variables = ["air_temperature_2m"] + elseif source == "ERA5" + product = "ERA5" + variables = ["2m_temperature"] + else + throw( + ArgumentError( + "Unsupported data source: $source. Supported data sources are 'NORA3' or 'ERA5'.", + ), + ) + end + df = get_met_data( + time_start, + time_end, + lat, + lon, + product, + variables; + data_path, + filename_hint, + reload_csv, + save_csv, + ) + df.heat_demand = temp_to_demand.(df[!, variables[1]]) + return df +end + +""" + get_met_data( + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + product::String, + variables::Vector{String}; + data_path::String = "metocean_api_data", + filename_hint::String = "", + reload_csv::Bool = true, + save_csv::Bool = true, + ) + +Fetches meteorological data for a specified time range and geographic location. + +# Arguments +- **`time_start::DateTime`**: Start of the time range for data retrieval. +- **`time_end::DateTime`**: End of the time range for data retrieval. +- **`lat::Real`**: Latitude of the location. +- **`lon::Real`**: Longitude of the location. +- **`product::String`**: Name of the meteorological data product to use. +- **`variables::Vector{String}`**: List of meteorological variables to retrieve. +- **`data_path::String`**: Directory path where data files are stored or will be saved. +- **`filename_hint::String`**: Hint for naming the output file. +- **`reload_csv::Bool`**: If true, reloads CSV data if available (default: true). +- **`save_csv::Bool`**: If `true`, saves the retrieved data as a CSV file (default: true). + +!!! note "Usage of the function" + * The function may download data from remote sources if not available locally. + * If `save_csv` is enabled, the data will be saved to a CSV file in the specified `data_path`. + * For use of the "ERA5" data source, the user needs to register and obtain a CDS API key. + This can be achieved by performing step 1: https://cds.climate.copernicus.eu/how-to-api +""" +function get_met_data( + time_start::DateTime, + time_end::DateTime, + lat::Real, + lon::Real, + product::String, + variables::Vector{String}; + data_path::String = "metocean_api_data", + filename_hint::String = "", + reload_csv::Bool = true, + save_csv::Bool = true, +) + # Ensure the cache directory exists + isdir(data_path) || mkpath(data_path) + + # Create a sanitized file hint for the cache file name + filehint = sanitize_filename_hint(filename_hint) + + csv_path = joinpath( + data_path, + product * "_" * Dates.format(time_start, "yyyymmdd") * "_" * + Dates.format(time_end, "yyyymmdd") * "_lat" * string(lat) * "_lon" * + string(lon) * filehint * ".csv", + ) + + if reload_csv && isfile(csv_path) && filesize(csv_path) > 0 + df = CSV.read( + csv_path, + DataFrame; + comment = "#", + dateformat = "yyyy-mm-dd HH:MM:SS", + types = Dict(:time => DateTime), + ) + rename!(df, :time => :time_utc) + else + ts = pyimport("metocean_api.ts") + ts_data = ts.TimeSeries( + lon = lon, + lat = lat, + start_time = Dates.format(time_start, "yyyy-mm-dd"), + end_time = Dates.format(time_end, "yyyy-mm-dd"), + product = product, + variable = variables, + datafile = nothing, + ) + ts_data.datafile = csv_path + ts_data.import_data(save_csv = save_csv, save_nc = false, use_cache = true) + idx_np = ts_data.data.index.to_numpy(copy = true) + time = DateTime(1970, 1, 1) .+ Nanosecond.(idx_np.astype("int64")) + data = ts_data.data.to_numpy(copy = true) + colnames = Symbol.(ts_data.data.columns.tolist()) + df = DataFrame([time data], [:time_utc; colnames...]) + # Ensure correct types + df.time_utc = DateTime.(df.time_utc) + for col โˆˆ colnames + df[!, col] = Float64.(df[!, col]) + end + end + if product == "ERA5" + rename!(df, "t2m" => "2m_temperature") + end + return df +end diff --git a/test/JuliaFormatter.jl b/test/JuliaFormatter.jl index 8fd04a5..8348dd7 100644 --- a/test/JuliaFormatter.jl +++ b/test/JuliaFormatter.jl @@ -1,7 +1,7 @@ using JuliaFormatter @testset "JuliaFormatter.jl" begin - @test format(joinpath(@__DIR__, "..", "src")) - @test format(joinpath(@__DIR__, "..", "test")) - @test format(joinpath(@__DIR__, "..", "ext")) + @test format(joinpath(testdir, "..", "src")) + @test format(joinpath(testdir, "..", "test")) + @test format(joinpath(testdir, "..", "ext")) end diff --git a/test/runtests.jl b/test/runtests.jl index 39e7f03..19dca59 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -21,19 +21,22 @@ testdir = joinpath(pkg_dir, "test") include(joinpath(testdir, "utils.jl")) @testset "EnergyModelsLanguageInterfaces" begin - ## Run all Aqua tests + # Run all Aqua tests include(joinpath(testdir, "Aqua.jl")) - ## Check if there is need for formatting + # Check if there is need for formatting include(joinpath(testdir, "JuliaFormatter.jl")) - ## Test sampling routines + # Test sampling routines include(joinpath(testdir, "test_sampling_routines.jl")) - ## Test checks + # Test checks include(joinpath(testdir, "test_checks.jl")) - ## Test nodes + # Test utils + include(joinpath(testdir, "test_utils.jl")) + + # Test nodes include(joinpath(testdir, "test_windpower.jl")) include(joinpath(testdir, "test_PV.jl")) include(joinpath(testdir, "test_buildings.jl")) diff --git a/test/test_buildings.jl b/test/test_buildings.jl index dcf2a9f..f55fa87 100644 --- a/test/test_buildings.jl +++ b/test/test_buildings.jl @@ -1,94 +1,231 @@ @testset "MultipleBuildingTypes" begin - for _ โˆˆ 1:2 # Run the test two times to also test running from stored files (from first run) - case, modeltype = simple_graph_buildings() + @testset "Utilities" begin + # Create the general data for the building node + ๐’ฏ = TwoLevel(2, 1, SimpleTimes(4, 1)) + heat = ResourceCarrier("Heat", 0.0) + power = ResourceCarrier("Power", 0.0) + building_node = MultipleBuildingTypes( + "Building", + Dict(heat => FixedProfile(100), power => FixedProfile(50)), + Dict(heat => FixedProfile(10), power => FixedProfile(5)), + Dict(heat => FixedProfile(20), power => FixedProfile(10)), + Dict(heat => 1.0, power => 0.5), + ) - buildings = get_node(case, "Buildings") # The MultipleBuildingTypes node - products = get_products(case) - building_res = products[1:(end-1)] # All resources except CO2 - CO2 = products[end] # The CO2 resource + # Test the EMB utility functions + @test EMB.capacity(building_node) == + Dict(heat => FixedProfile(100), power => FixedProfile(50)) + @test EMB.capacity(building_node, heat) == FixedProfile(100) + @test EMB.capacity(building_node, power) == FixedProfile(50) + @test EMB.capacity(building_node, first(๐’ฏ), heat) == 100 + @test EMB.capacity(building_node, first(๐’ฏ), power) == 50 + @test EMB.surplus_penalty(building_node) == + Dict(heat => FixedProfile(10), power => FixedProfile(5)) + @test EMB.surplus_penalty(building_node, heat) == FixedProfile(10) + @test EMB.surplus_penalty(building_node, power) == FixedProfile(5) + @test EMB.surplus_penalty(building_node, first(๐’ฏ), heat) == 10 + @test EMB.surplus_penalty(building_node, first(๐’ฏ), power) == 5 + @test EMB.deficit_penalty(building_node) == + Dict(heat => FixedProfile(20), power => FixedProfile(10)) + @test EMB.deficit_penalty(building_node, heat) == FixedProfile(20) + @test EMB.deficit_penalty(building_node, power) == FixedProfile(10) + @test EMB.deficit_penalty(building_node, first(๐’ฏ), heat) == 20 + @test EMB.deficit_penalty(building_node, first(๐’ฏ), power) == 10 + @test heat โˆˆ EMB.inputs(building_node) + @test power โˆˆ EMB.inputs(building_node) + @test EMB.has_capacity(building_node) == false + end - # Run the model - m = EMB.run_model(case, modeltype, OPTIMIZER) + @testset "Mathematical formulation" begin + for _ โˆˆ 1:2 # Run the test two times to also test running from stored files (from first run) + case, modeltype = simple_graph_buildings() - # Extraction of the time structure - ๐’ฏ = get_time_struct(case) + buildings = get_node(case, "Buildings") # The MultipleBuildingTypes node + products = get_products(case) + building_res = products[1:(end-1)] # All resources except CO2 + CO2 = products[end] # The CO2 resource - # Run of the general tests - general_tests(m) + # Run the model + m = EMB.run_model(case, modeltype, OPTIMIZER) - @test all( - value.(m[:buildings_surplus][buildings, t, p]) == 0.0 for - t โˆˆ ๐’ฏ, p โˆˆ building_res - ) - @test all( - value.(m[:buildings_deficit][buildings, t, p]) == 0.0 for - t โˆˆ ๐’ฏ, p โˆˆ building_res - ) - @test all(value.(m[:emissions_total][t, CO2]) > 1e3 for t โˆˆ ๐’ฏ) + # Extraction of the time structure + ๐’ฏ = get_time_struct(case) - # Test that the EMB function has_capacity is false for the MultipleBuildingTypes node. - @test !EMB.has_capacity(buildings) + # Run of the general tests + general_tests(m) - # Test constraints from EMB.constraints_capacity - @test all( - value.(m[:flow_in][buildings, t, p]) / inputs(buildings, p) + - value.(m[:buildings_deficit][buildings, t, p]) == - EMB.capacity(buildings, t, p) + value.(m[:buildings_surplus][buildings, t, p]) - for t โˆˆ ๐’ฏ, p โˆˆ inputs(buildings) - ) + @test all( + value.(m[:buildings_surplus][buildings, t, p]) == 0.0 for + t โˆˆ ๐’ฏ, p โˆˆ building_res + ) + @test all( + value.(m[:buildings_deficit][buildings, t, p]) == 0.0 for + t โˆˆ ๐’ฏ, p โˆˆ building_res + ) + @test all(value.(m[:emissions_total][t, CO2]) > 1e3 for t โˆˆ ๐’ฏ) - @test all( - sum(value.(m[:buildings_deficit][buildings, t, p]) for p โˆˆ inputs(buildings)) == - value.(m[:sink_deficit][buildings, t]) - for t โˆˆ ๐’ฏ - ) + # Test that the EMB function has_capacity is false for the MultipleBuildingTypes node. + @test !EMB.has_capacity(buildings) - @test all( - sum(value.(m[:buildings_surplus][buildings, t, p]) for p โˆˆ inputs(buildings)) == - value.(m[:sink_surplus][buildings, t]) - for t โˆˆ ๐’ฏ - ) + # Test constraints from EMB.constraints_capacity + @test all( + value.(m[:flow_in][buildings, t, p]) / inputs(buildings, p) + + value.(m[:buildings_deficit][buildings, t, p]) == + EMB.capacity(buildings, t, p) + + value.(m[:buildings_surplus][buildings, t, p]) + for t โˆˆ ๐’ฏ, p โˆˆ inputs(buildings) + ) - # Test constraints from EMB.constraints_opex_var - ๐’ฏแดตโฟแต› = strategic_periods(๐’ฏ) - @test all( - value.(m[:opex_var][buildings, t_inv]) == - sum( - ( - value.(m[:buildings_surplus][buildings, t, p]) * - EMB.surplus_penalty(buildings, t, p) + - value.(m[:buildings_deficit][buildings, t, p]) * - EMB.deficit_penalty(buildings, t, p) - ) * scale_op_sp(t_inv, t) for t โˆˆ t_inv, p โˆˆ inputs(buildings) + @test all( + sum( + value.(m[:buildings_deficit][buildings, t, p]) for p โˆˆ inputs(buildings) + ) == + value.(m[:sink_deficit][buildings, t]) + for t โˆˆ ๐’ฏ ) - for t_inv โˆˆ ๐’ฏแดตโฟแต› - ) - # Test the utility functions for MultipleBuildingTypes - for p โˆˆ building_res - # Capacity - @test EMB.capacity(buildings) isa Dict - @test EMB.capacity(buildings, p) isa TimeProfile - @test EMB.capacity(buildings)[p] == EMB.capacity(buildings, p) - @test EMB.capacity(buildings, first(๐’ฏ), p) == - EMB.capacity(buildings, p)[first(๐’ฏ)] - - # Surplus penalty - @test EMB.surplus_penalty(buildings) isa Dict - @test EMB.surplus_penalty(buildings, p) isa TimeProfile - @test EMB.surplus_penalty(buildings)[p] == EMB.surplus_penalty(buildings, p) - @test EMB.surplus_penalty(buildings, first(๐’ฏ), p) == - EMB.surplus_penalty(buildings, p)[first(๐’ฏ)] - - # Deficit penalty - @test EMB.deficit_penalty(buildings) isa Dict - @test EMB.deficit_penalty(buildings, p) isa TimeProfile - @test EMB.deficit_penalty(buildings)[p] == EMB.deficit_penalty(buildings, p) - @test EMB.deficit_penalty(buildings, first(๐’ฏ), p) == - EMB.deficit_penalty(buildings, p)[first(๐’ฏ)] + @test all( + sum( + value.(m[:buildings_surplus][buildings, t, p]) for p โˆˆ inputs(buildings) + ) == + value.(m[:sink_surplus][buildings, t]) + for t โˆˆ ๐’ฏ + ) + + # Test constraints from EMB.constraints_opex_var + ๐’ฏแดตโฟแต› = strategic_periods(๐’ฏ) + @test all( + value.(m[:opex_var][buildings, t_inv]) == + sum( + ( + value.(m[:buildings_surplus][buildings, t, p]) * + EMB.surplus_penalty(buildings, t, p) + + value.(m[:buildings_deficit][buildings, t, p]) * + EMB.deficit_penalty(buildings, t, p) + ) * scale_op_sp(t_inv, t) for t โˆˆ t_inv, p โˆˆ inputs(buildings) + ) + for t_inv โˆˆ ๐’ฏแดตโฟแต› + ) + + # Test the utility functions for MultipleBuildingTypes + for p โˆˆ building_res + # Capacity + @test EMB.capacity(buildings) isa Dict + @test EMB.capacity(buildings, p) isa TimeProfile + @test EMB.capacity(buildings)[p] == EMB.capacity(buildings, p) + @test EMB.capacity(buildings, first(๐’ฏ), p) == + EMB.capacity(buildings, p)[first(๐’ฏ)] + + # Surplus penalty + @test EMB.surplus_penalty(buildings) isa Dict + @test EMB.surplus_penalty(buildings, p) isa TimeProfile + @test EMB.surplus_penalty(buildings)[p] == EMB.surplus_penalty(buildings, p) + @test EMB.surplus_penalty(buildings, first(๐’ฏ), p) == + EMB.surplus_penalty(buildings, p)[first(๐’ฏ)] + + # Deficit penalty + @test EMB.deficit_penalty(buildings) isa Dict + @test EMB.deficit_penalty(buildings, p) isa TimeProfile + @test EMB.deficit_penalty(buildings)[p] == EMB.deficit_penalty(buildings, p) + @test EMB.deficit_penalty(buildings, first(๐’ฏ), p) == + EMB.deficit_penalty(buildings, p)[first(๐’ฏ)] + end + + # Test has_capacity utility function + @test EMB.has_capacity(buildings) == false end + end +end + +@testset "Building" begin + @testset "Utilities" begin + # Create the general data for the building node + ๐’ฏ = TwoLevel(2, 1, SimpleTimes(4, 1)) + heat = ResourceCarrier("Heat", 0.0) + building_node = Building( + "Building", + Dict(heat => FixedProfile(100)), + Dict(heat => FixedProfile(10)), + Dict(heat => FixedProfile(20)), + Dict(heat => 1.0), + ) + + # Test the EMB utility functions + @test EMB.capacity(building_node) == Dict(heat => FixedProfile(100)) + @test EMB.capacity(building_node, heat) == FixedProfile(100) + @test EMB.capacity(building_node, first(๐’ฏ), heat) == 100 + @test EMB.surplus_penalty(building_node) == Dict(heat => FixedProfile(10)) + @test EMB.surplus_penalty(building_node, heat) == FixedProfile(10) + @test EMB.surplus_penalty(building_node, first(๐’ฏ), heat) == 10 + @test EMB.deficit_penalty(building_node) == Dict(heat => FixedProfile(20)) + @test EMB.deficit_penalty(building_node, heat) == FixedProfile(20) + @test EMB.deficit_penalty(building_node, first(๐’ฏ), heat) == 20 + @test EMB.inputs(building_node) == [heat] + @test EMB.has_capacity(building_node) == false + end + + @testset "Mathematical formulation" begin + for _ โˆˆ 1:2 # Run the test two times to also test running from stored files (from first run) + case, modeltype, m = simple_graph_building() + + # Run the model + m = EMB.run_model(case, modeltype, OPTIMIZER) - # Test has_capacity utility function - @test EMB.has_capacity(buildings) == false + building = get_node(case, "Building") + sink = get_node(case, "Source for HeatHT") + products = get_products(case) + building_res = products[1:(end-1)] # All resources except CO2 + HeatHT = fetch_element(products, "HeatHT") + + # Extraction of the time structure + ๐’ฏ = get_time_struct(case) + + # Run of the general tests + general_tests(m) + + # Test that the source data is the same + ref_values = OperationalProfile([ + 15.00, + 15.61, + 16.37, + 16.67, + 16.78, + 17.70, + 17.80, + 17.91, + 17.90, + 16.98, + 14.91, + 14.02, + 14.01, + 14.45, + 14.91, + 15.45, + 15.87, + 16.67, + 16.58, + 17.04, + 17.57, + 17.95, + 17.76, + 18.03, + ]) + + @test all( + isapprox( + value.(m[:flow_out][sink, t, HeatHT]), + ref_values[t]; + atol = TEST_ATOL, + ) for t โˆˆ ๐’ฏ + ) + @test all( + value.(m[:buildings_surplus][building, t, p]) == 0.0 for + t โˆˆ ๐’ฏ, p โˆˆ building_res + ) + @test all( + value.(m[:buildings_deficit][building, t, p]) == 0.0 for + t โˆˆ ๐’ฏ, p โˆˆ building_res + ) + end end end diff --git a/test/test_checks.jl b/test/test_checks.jl index c413717..51cc96e 100644 --- a/test/test_checks.jl +++ b/test/test_checks.jl @@ -72,6 +72,11 @@ end ) end +@testset "Test checks - Building" begin + # Test that a unsupported source is caught by the checks + @test_throws ArgumentError simple_graph_building(; source = "unsupported_source") +end + @testset "Test checks - CSPandPV" begin # Test missing resource in cap @test_throws AssertionError simple_graph_csp_pv(; diff --git a/test/test_utils.jl b/test/test_utils.jl new file mode 100644 index 0000000..ce36584 --- /dev/null +++ b/test/test_utils.jl @@ -0,0 +1,72 @@ +@testset "heat_demand_profile utility function" begin + for _ โˆˆ 1:2 # Run the test two times to also test running from stored files (from first run) + profiles = [] + for source โˆˆ ["NORA3", "ERA5"] + df = get_heat_demand_profile(; source) + push!(profiles, df.heat_demand) + end + + ref_values = [ + [ + 12.37, + 12.26, + 12.09, + 12.08, + 11.92, + 12.74, + 13.71, + 13.81, + 13.91, + 13.77, + 13.79, + 13.75, + 14.29, + 14.65, + 14.98, + 16.54, + 15.52, + 15.87, + 16.14, + 16.16, + 16.18, + 16.17, + 16.07, + 16.53, + ], + [ + 12.01, + 11.91, + 11.76, + 11.61, + 11.49, + 12.22, + 13.07, + 13.11, + 13.14, + 13.14, + 13.27, + 13.26, + 13.87, + 14.51, + 14.6, + 14.7, + 14.57, + 14.98, + 15.19, + 15.26, + 15.09, + 15.12, + 14.87, + 15.05, + ], + ] + + for (ref_value, profile) โˆˆ zip(ref_values, profiles) + @test isapprox( + ref_value, + profile; + atol = TEST_ATOL, + ) + end + end +end diff --git a/test/utils.jl b/test/utils.jl index 693057b..9da999d 100644 --- a/test/utils.jl +++ b/test/utils.jl @@ -82,7 +82,7 @@ function simple_graph_wind(; "shape" => missing, "turbine_height" => 150, ) - data_path = mkpath(joinpath(@__DIR__, "downloaded_nora3")) + data_path = mkpath(joinpath(testdir, "data", "WindPower")) if isnothing(profile) wind = WindPower( "Windfarm", # Node id @@ -218,6 +218,79 @@ function simple_graph_buildings(; cap_p = nothing, return case, modeltype, create_model(case, modeltype) end +function simple_graph_building(; cap_p = nothing, + penalty_surplus = Dict(HeatHT=>FixedProfile(100), Power=>FixedProfile(100)), + penalty_deficit = Dict(HeatHT=>FixedProfile(1e4), Power=>FixedProfile(1e4)), + input = Dict(HeatHT=>1.0, Power=>1.0), source = "NORA3") + # Creation of the initial problem with the NonDisRES node + time_start_str = "2019-01-01" + time_end_str = "2019-01-01" + op_duration = 1 + op_number = 24 * (Dates.value(Date(time_end_str) - Date(time_start_str)) + 1) + operational_periods = SimpleTimes(op_number, op_duration) + + sp_duration = [1, 2, 10] + T = TwoLevel(sp_duration, operational_periods; op_per_strat = 8760.0) + + time_start = DateTime(time_start_str * "T00:00:00") + time_end = DateTime(time_end_str * "T23:00:00") + + building_res = [Power, HeatHT] + products = [building_res..., CO2] + + sources = [ + RefSource( + "Source for " * resource.id, + FixedProfile(1e4), + FixedProfile(120), + FixedProfile(0), + Dict(resource => 1.0), + ) for resource โˆˆ building_res + ] + if isnothing(cap_p) + # Example temp_to_demand function (replace with your actual function) + temp_to_demand(temp) = max(0, 20 - (temp - 273.15)) + # Example location (replace with actual values or make it an argument) + lat, lon = 59.91, 10.75 # Oslo coordinates as example + cap = Dict( + resource => FixedProfile(120) for resource โˆˆ building_res if resource != HeatHT + ) + building = Building( + "Building", + cap, + penalty_surplus, + penalty_deficit, + input, + time_start, + time_end, + lat, + lon, + HeatHT, + temp_to_demand; + data_path = joinpath(pkgdir(EMLI), "test", "data", "building"), + source, + ) + else + building = Building( + "Building", + cap_p, + penalty_surplus, + penalty_deficit, + input, + ) + end + + nodes = [building, sources...] + links = [Direct(node.id * "-Building", node, building, Linear()) for node โˆˆ sources] + + case = Case(T, products, [nodes, links], [[get_nodes, get_links]]) + + em_limits = Dict(CO2 => FixedProfile(1e10)) # Emission cap for COโ‚‚ in t/year + em_cost = Dict(CO2 => FixedProfile(71.0)) # Emission price for COโ‚‚ in โ‚ฌ/t + modeltype = OperationalModel(em_limits, em_cost, CO2) + return case, modeltype, create_model(case, modeltype) +end + function simple_graph_csp_pv(; cap_p = nothing, profile = Dict(Power=>FixedProfile(0.8), CSPHeat=>FixedProfile(0.7)), opex_var_p = Dict(Power=>FixedProfile(0.1), CSPHeat=>FixedProfile(0.2)), @@ -494,3 +567,18 @@ function get_node(case::Case, id) elements = get_nodes(case) return EMLI.fetch_element(elements, id) end + +function get_heat_demand_profile(; source = "NORA3") + return EMLI.heat_demand_profile( + DateTime("2019-01-01T00:00:00"), + DateTime("2019-01-01T23:00:00"), + 55, # lat + 9, # lon + temp -> max(0, 20 - (temp - 273.15)); + data_path = joinpath(testdir, "data", "heat_demand_profile_test", source), + filename_hint = "Denmark", + source = source, + reload_csv = true, + save_csv = true, + ) +end